Skip to content
All posts
May 16, 20265 min read

I Stopped Manually Building Android Apps — Here's My GitHub Actions Pipeline

How I replaced every manual build, test, and upload step with a GitHub Actions workflow that deploys to the Play Store on a single push.

AndroidCI/CD
Share:

I shipped 22+ apps and still spent every release day clicking buttons in Android Studio. Then I automated the entire pipeline and never looked back.

If you're a solo Android developer managing multiple apps, manual builds eat hours you don't have. One misconfigured signing config and you're debugging keystores at midnight. I built a GitHub Actions workflow that builds, tests, lint-checks, and deploys to the Play Store on every push to main. Here's exactly how it works.

The Pipeline Architecture

My workflow runs on every push to the main branch. It does four things in sequence:

  1. Checks out the code
  2. Runs lint, unit tests, and instrumentation tests
  3. Builds signed APKs and AABs
  4. Uploads to the internal Play Store track via the Google Play API

Before I built this, a release day looked like: open Android Studio, wait for Gradle sync, click Build Bundle, sign manually, open Play Console, upload, wait. That's 45 minutes of my day gone. Now it happens while I'm sleeping.

Here's the full workflow:

yaml
name: Android CI/CD

on:
  push:
    branches: [main]
  workflow_dispatch:

env:
  SCORAGE_BUCKET: sudarshan-tech-labs-releases
  PLAY_STORE_TRACK: internal

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    timeout-minutes: 30

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

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

      - name: Run lint and tests
        run: ./gradlew lintDebug testDebugUnitTest --no-daemon

      - name: Build AAB
        run: ./gradlew bundleRelease --no-daemon

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

      - name: Deploy to Play Store
        if: github.ref == 'refs/heads/main'
        uses: r0adkll/upload-google-play@v1
        with:
          serviceAccountJsonPlainText: ${{ secrets.PLAY_STORE_SERVICE_ACCOUNT }}
          packageName: com.sudarshantechlabs.appname
          releaseFiles: app/build/outputs/bundle/release/app-release.aab
          track: ${{ env.PLAY_STORE_TRACK }}
          rollout: '0.10'

[!IMPORTANT] Store your Play Store service account JSON in GitHub Secrets. Never commit it. Ever.

What Happens When It Breaks

CI catches things I used to find on-device at 2 AM. Lint failures, test regressions, ProGuard rule conflicts — all caught before they reach a user.

I added a matrix build for my multi-module setup. Most of my apps share a common module, so I test that module independently before building the app module.

kotlin
// build.gradle.kts - test matrix configuration
tasks.register("testAllModules") {
    dependsOn(":common:lintDebug")
    dependsOn(":common:testDebugUnitTest")
    dependsOn(":app:lintDebug")
    dependsOn(":app:testDebugUnitTest")
    dependsOn(":app:bundleRelease")
}

I map this task in my GitHub Actions step so the full test suite runs before any bundle is created.

yaml
- name: Run full test matrix
  run: ./gradlew testAllModules --no-daemon

Here's what the output looks like when something fails:

code
> Task :app:testDebugUnitTest FAILED

com.sudarshantechlabs.appname.core.TestAuthFlow > login_with_invalid_creds FAILED
  org.gradle.api.GradleException: Test failed

I get this email before I even open my laptop. That's the point.

[!WARNING] Don't skip instrumentation tests in CI. UI bugs are your most expensive bugs. Running them catches regressions in navigation, screen rotation, and configuration changes that unit tests miss entirely.

Play Store Deployment Without Play Console UI

The

code
r0adkll/upload-google-play
action uses the Google Play Developer API. You create a service account in Google Cloud Console, grant it "Release Manager" role in Play Console, and store the JSON key as a GitHub Secret.

bash
# Create service account via gcloud
gcloud iam service-accounts create play-store-bot \
  --display-name "Play Store Deploy Bot"

gcloud projects add-iam-policy-binding YOUR_PROJECT_ID \
  --member="serviceAccount:play-store-bot@YOUR_PROJECT.iam.gserviceaccount.com" \
  --role="roles/serviceusage.serviceUsageConsumer"

# Grant role in Play Console
# Service Accounts & APIs > Grant access > Release Manager

Once that's done, the action handles upload, rollout percentage, and track targeting. I deploy to internal track first, then promote to production after manual review.

StepBefore CI/CDAfter CI/CD
Build12 min manual8 min automated
Lint checkMissed half the timeEvery push
Test runLocal onlyGitHub-hosted on every push
Play Store uploadManual drag-and-dropAPI call, < 2 min
RollbackManualRe-deploy previous tag

The numbers aren't theoretical. Over the last 6 months, this pipeline saved me roughly 20 hours of release-day overhead across 4 apps.

[!TIP] Pin your Gradle wrapper versions in CI. A Gradle upgrade that changes caching behavior can silently break build times. Lock it with

code
--gradle-version
in your setup-java step.

Handling Secrets and Signing in CI

I use a

code
signingConfigs
block in my app-level
code
build.gradle.kts
that reads from environment variables. GitHub Actions injects these via repository secrets.

kotlin
android {
    signingConfigs {
        create("play store") {
            val keystoreUser = System.getenv("KEYSTORE_USER") ?: ""
            val keystorePass = System.getenv("KEYSTORE_PASS") ?: ""
            val keyAlias = System.getenv("KEY_ALIAS") ?: ""
            val keyPass = System.getenv("KEY_PASSWORD") ?: ""

            storeUser = keystoreUser
            storePassword = keystorePass
            keyAlias = keyAlias
            keyPassword = keyPass
        }
    }

    buildTypes {
        release {
            signingConfig = signingConfigs.getByName("play store")
            isMinifyEnabled = true
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro"
            )
        }
    }
}

[!NOTE] I keep three signing configs: one for debug, one for release-internal, and one for production. The CI workflow uses the internal config by default and promotes to production only on a tagged release.

Key Takeaways

  • Set up the GitHub Actions workflow above for any Android project — it takes under 30 minutes and eliminates manual build steps permanently.
  • Store your Play Store service account JSON in GitHub Secrets and never commit it to your repo.
  • Run lint, unit tests, and instrumentation tests in CI on every push; catches regressions before they reach users.
  • Use environment-variable-based signing configs so your CI and local builds share the same signing logic without exposing secrets.
  • Pin Gradle versions and wrapper versions in CI to avoid silent build-time regressions.
Share:
S

Sudarshan Chaudhari

AI Systems Builder / Product Engineer

Bangkok, Thailand

Solo Android developer with 13+ years in QA, building Android apps, AI automation systems, and developer tools at SudarshanTechLabs.

Stay updated

Get new posts on Android, Kotlin, and solo dev straight to your inbox.

Newsletter preferences

Related Apps

MyFamilyTracker

Real-time family location sharing — Firebase Realtime DB for sub-second propagation, WorkManager + ForegroundService for OS-compliant background collection, geofencing via Google Maps API.

Building something? Available for Android dev and QA consulting.

Work with me

Comments — powered by Giscus