Skip to content

Deploy Android + Android TV (Google Play) #16

Deploy Android + Android TV (Google Play)

Deploy Android + Android TV (Google Play) #16

# Deploy Android + Android TV (Google Play)
#
# Architectures: arm64-v8a, armeabi-v7a, x86, x86_64 (all ABIs in one AAB)
# Output: Signed AAB uploaded to Google Play via Fastlane
# release/beta → beta track
# master → production track
# Android versionCode is derived from CMake semver (see android/app/build.gradle).
#
# Required secrets:
# ANDROID_KEYSTORE_BASE64 - base64 of upload keystore (.jks)
# ANDROID_KEYSTORE_PASSWORD - keystore password
# ANDROID_KEY_ALIAS - signing key alias
# ANDROID_KEY_PASSWORD - signing key password
# GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64 - base64 of service account JSON key
name: Deploy Android + Android TV (Google Play)
on:
workflow_dispatch:
inputs:
build_apk:
description: "APK-only: build a sideloadable test APK artifact and SKIP the Google Play publish"
type: boolean
default: false
workflow_call:
outputs:
chiaki_version:
description: Pylux version from CMakeLists.txt
value: ${{ jobs.build-android.outputs.chiaki_version }}
play_testing_url:
description: Google Play open testing opt-in URL
value: ${{ jobs.build-android.outputs.play_testing_url }}
play_store_url:
description: Google Play store listing URL
value: ${{ jobs.build-android.outputs.play_store_url }}
android_upload_succeeded:
description: Whether the Fastlane Play upload step succeeded
value: ${{ jobs.build-android.outputs.android_upload_succeeded }}
concurrency:
group: build-android-${{ github.ref }}
cancel-in-progress: true
jobs:
build-android:
name: Build and deploy to Google Play
runs-on: ubuntu-latest
timeout-minutes: 60
outputs:
chiaki_version: ${{ steps.extract_version.outputs.version }}
play_testing_url: ${{ steps.play_links.outputs.play_testing_url }}
play_store_url: ${{ steps.play_links.outputs.play_store_url }}
android_upload_succeeded: ${{ steps.upload_play.outcome == 'success' }}
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Extract version
id: extract_version
uses: ./.github/actions/extract-version
- name: Play store links (workflow outputs)
id: play_links
if: always()
run: |
echo "play_testing_url=https://play.google.com/apps/testing/com.pylux.stream" >> "$GITHUB_OUTPUT"
echo "play_store_url=https://play.google.com/store/apps/details?id=com.pylux.stream" >> "$GITHUB_OUTPUT"
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
- name: Set up Android SDK
uses: android-actions/setup-android@v3
- name: Install NDK and CMake
run: |
sdkmanager --install \
"ndk;28.2.13676358" \
"cmake;3.30.4" \
"build-tools;35.0.0" \
"platforms;android-35"
- name: Install protobuf tools
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq protobuf-compiler
pip3 install 'protobuf>=5,<6' 'grpcio-tools>=1.60'
- name: Decode keystore
env:
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
run: |
if [ -z "$KEYSTORE_BASE64" ]; then
echo "::warning::ANDROID_KEYSTORE_BASE64 not set — building unsigned"
echo "SIGNING_CONFIGURED=false" >> "$GITHUB_ENV"
else
KEYSTORE_PATH="$RUNNER_TEMP/release.jks"
echo "$KEYSTORE_BASE64" > "$RUNNER_TEMP/keystore.b64"
base64 -d -i "$RUNNER_TEMP/keystore.b64" > "$KEYSTORE_PATH"
rm -f "$RUNNER_TEMP/keystore.b64"
echo "KEYSTORE_PATH=$KEYSTORE_PATH" >> "$GITHUB_ENV"
echo "SIGNING_CONFIGURED=true" >> "$GITHUB_ENV"
fi
- name: Write local.properties
working-directory: android
env:
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: |
echo "sdk.dir=$ANDROID_SDK_ROOT" > local.properties
if [ "$SIGNING_CONFIGURED" = "true" ]; then
cat >> local.properties <<PROPS
chiakiKeystore=${{ env.KEYSTORE_PATH }}
chiakiKeystorePW=$KEYSTORE_PASSWORD
chiakiKeyAlias=$KEY_ALIAS
chiakiKeyPW=$KEY_PASSWORD
PROPS
echo "Signing configured in local.properties"
else
echo "No signing — unsigned build only"
fi
# Skipped on APK-only runs (build_apk checkbox): those just want a sideloadable
# APK and must not touch Google Play.
- name: Build release AAB
id: bundle_release
if: ${{ !inputs.build_apk }}
working-directory: android
run: |
./gradlew bundleRelease \
--parallel \
--build-cache \
-Dorg.gradle.java.home="$JAVA_HOME"
# Optional sideloadable APK. The AAB above can only go through Google Play,
# so this opt-in step (the "build_apk" checkbox on a manual run) produces an
# installable .apk testers can grab from the run's Artifacts. A signed release
# APK is built when signing is configured; otherwise a debug APK (still
# installable, self-signed) so the step never fails for lack of secrets.
- name: Build sideloadable APK
id: build_apk
if: ${{ inputs.build_apk }}
working-directory: android
run: |
# Plain assemble (NO android.injected.build.* properties): those mark the APK
# testOnly=true, which blocks normal sideloading (INSTALL_FAILED_TEST_ONLY / "can't
# install on this device"). abiFilters in build.gradle already limits native libs to the
# ABIs that were built (arm64-v8a), so this stays effectively arm64 while remaining
# installable from a file manager / the Downloads folder.
if [ "$SIGNING_CONFIGURED" = "true" ]; then
echo "Signing configured — building signed release APK"
./gradlew assembleRelease \
--parallel --build-cache -Dorg.gradle.java.home="$JAVA_HOME"
else
echo "::warning::No signing configured — building debug APK (self-signed, still installable for testing)"
./gradlew assembleDebug \
--parallel --build-cache -Dorg.gradle.java.home="$JAVA_HOME"
fi
# Newest final APK from the outputs dir (outputs/ only, so we never pick an intermediate
# unsigned APK). xargs -r so an empty find doesn't run `ls` on the cwd and yield a bogus path.
APK="$(find app/build/outputs/apk -name '*.apk' -print0 2>/dev/null | xargs -0 -r ls -t 2>/dev/null | head -1)"
test -n "$APK" || { echo "::error::No APK produced"; exit 1; }
SHORT_SHA="$(git rev-parse --short HEAD)"
DEST="pylux-${{ steps.extract_version.outputs.version }}-${SHORT_SHA}.apk"
cp "$APK" "$GITHUB_WORKSPACE/$DEST"
echo "apk_name=$DEST" >> "$GITHUB_OUTPUT"
echo "Built sideloadable APK: $DEST"
- name: Upload APK artifact
if: ${{ inputs.build_apk }}
uses: actions/upload-artifact@v4
with:
name: pylux-android-apk
path: ${{ steps.build_apk.outputs.apk_name }}
if-no-files-found: error
- name: Decode Google Play service account key
if: ${{ !inputs.build_apk }}
env:
JSON_BASE64: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64 }}
run: |
if [ -z "$JSON_BASE64" ]; then
echo "::warning::GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64 not set — skipping upload"
echo "UPLOAD_ENABLED=false" >> "$GITHUB_ENV"
else
KEY_PATH="$RUNNER_TEMP/play-credentials.json"
echo "$JSON_BASE64" > "$RUNNER_TEMP/play-key.b64"
base64 -d -i "$RUNNER_TEMP/play-key.b64" > "$KEY_PATH"
rm -f "$RUNNER_TEMP/play-key.b64"
echo "GOOGLE_PLAY_JSON_KEY_PATH=$KEY_PATH" >> "$GITHUB_ENV"
echo "UPLOAD_ENABLED=true" >> "$GITHUB_ENV"
fi
- name: Determine Google Play track
if: ${{ env.UPLOAD_ENABLED == 'true' && !inputs.build_apk }}
run: |
if [ "${{ github.ref_name }}" = "master" ]; then
TRACK=production
else
TRACK=beta
fi
echo "PYLUX_PLAY_TRACK=$TRACK" >> "$GITHUB_ENV"
echo "Deploying to Google Play track: $TRACK"
- name: Upload AAB to Google Play
id: upload_play
if: ${{ env.UPLOAD_ENABLED == 'true' && !inputs.build_apk }}
working-directory: android
env:
# Same value Gradle uses (extract-version semver_build_id); avoids Fastlane relying on a solo Gradle invoke.
ANDROID_VERSION_CODE: ${{ steps.extract_version.outputs.semver_build_id }}
run: |
sudo gem install fastlane -N
AAB_PATH="$(find app/build/outputs/bundle/release -name '*.aab' | head -1)"
export PYLUX_AAB_PATH="$PWD/$AAB_PATH"
fastlane upload_pylux_aab
- name: Deployment summary
if: always()
run: |
{
echo "## Pylux Android — v${CHIAKI_VERSION:-?}"
echo ""
if [ "${{ inputs.build_apk }}" = "true" ]; then
echo "APK-only run — Google Play publish was skipped."
echo ""
if [ "${{ steps.build_apk.outcome }}" = "success" ]; then
echo "Sideloadable test APK \`${{ steps.build_apk.outputs.apk_name }}\` is attached to this run (see the **Artifacts** section, named \`pylux-android-apk\`)."
else
echo "APK build did not complete (outcome: **${{ steps.build_apk.outcome }}**). Check the logs above for details."
fi
elif [ "${{ steps.bundle_release.outcome }}" != "success" ]; then
echo "Build did not complete (outcome: **${{ steps.bundle_release.outcome }}**). Check the logs above for details."
else
case "${UPLOAD_ENABLED:-false}" in
true)
if [ "${{ steps.upload_play.outcome }}" = "success" ]; then
if [ "${{ github.ref_name }}" = "master" ]; then
echo "Released to the Play Store:"
echo ""
echo "**[Get it on Google Play](https://play.google.com/store/apps/details?id=com.pylux.stream)**"
echo ""
echo "Available for Android phones, tablets, and Android TV."
else
echo "Beta build uploaded to Google Play."
echo ""
echo "**[Join the beta](https://play.google.com/apps/testing/com.pylux.stream)**"
echo ""
echo "1. Open the join link above to opt in to the beta program."
echo "2. Install or update Pylux from the [Play Store](https://play.google.com/store/apps/details?id=com.pylux.stream)."
echo "3. Android TV: join the beta on a phone or browser first, then install from the Play Store on your TV."
fi
else
echo "Upload did not complete (outcome: **${{ steps.upload_play.outcome }}**). Check the logs above for details."
fi
;;
*)
echo "Google Play upload was skipped (secret not configured)."
;;
esac
fi
} >> "$GITHUB_STEP_SUMMARY"