PCSalt
YouTube GitHub
Back to Android
Android · 3 min read

Automating Android Repo Maintenance with Reusable GitHub Actions

How I modernized 8 Android repos in one session — reusable CI workflows, batch build system updates, and a pattern for maintaining multiple projects at scale.


I maintain several Android sample repos that accompany blog posts on this site. Over time, they drifted — different AGP versions, some on Groovy, some on Kotlin DSL, one still using Eclipse project format from 2013. None had CI. If a repo silently broke after a dependency update, I wouldn’t know until someone opened an issue.

I decided to fix all of them in one pass. Here’s how.

The Problem

Eight repos, each in a different state:

RepoAGPGradleLanguageBuild System
SharedPreference-Demo1.3.02.4JavaGroovy
create-alertdialog-android8.7.08.9KotlinKotlin DSL
custom-alertdialog-programmatically8.7.08.9KotlinKotlin DSL
ListViewDemo4.1.36.5KotlinGroovy
ChipDemo4.2.16.7.1KotlinGroovy
ClipboardManagerJavaEclipse
badge-count-navigation-menu4.1.06.3KotlinGroovy
product-flavours4.2.06.7JavaGroovy

The goal: bring every repo to AGP 9.1.0, Gradle 9.3.1, Kotlin DSL, version catalog, minSdk 34, targetSdk 36, Material3 — and add CI to all of them.

Step 1: Create a Reusable CI Workflow

Instead of copying a 40-line workflow file to 8 repos, I created a single reusable workflow in a dedicated public repo: krrishnaaaa/android-ci.

The reusable workflow

.github/workflows/build.yml in the android-ci repo:

name: Android Build

on:
  workflow_call:
    inputs:
      java-version:
        required: false
        type: string
        default: '21'
      gradle-task:
        required: false
        type: string
        default: 'assembleDebug'
      test-task:
        required: false
        type: string
        default: 'test'
      run-tests:
        required: false
        type: boolean
        default: true
      upload-artifact:
        required: false
        type: boolean
        default: true
      artifact-path:
        required: false
        type: string
        default: 'app/build/outputs/apk/debug/*.apk'
      artifact-name:
        required: false
        type: string
        default: 'debug-apk'

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-java@v4
        with:
          java-version: ${{ inputs.java-version }}
          distribution: 'temurin'

      - uses: gradle/actions/setup-gradle@v4
        with:
          cache-read-only: ${{ github.ref != format('refs/heads/{0}', github.event.repository.default_branch) }}

      - name: Build
        run: ./gradlew ${{ inputs.gradle-task }}

      - name: Run tests
        if: inputs.run-tests
        run: ./gradlew ${{ inputs.test-task }}

      - name: Upload artifact
        if: inputs.upload-artifact
        uses: actions/upload-artifact@v4
        with:
          name: ${{ inputs.artifact-name }}
          path: ${{ inputs.artifact-path }}
          retention-days: 14

Key design decisions:

  • workflow_call — makes it callable from other repos
  • All inputs optional — sensible defaults mean most repos need zero configuration
  • Gradle cachingsetup-gradle caches dependencies automatically, cache-read-only prevents PRs from polluting the cache
  • Configurable — product flavor repos can override artifact-path, repos without tests can set run-tests: false

The caller workflow

In each Android repo, .github/workflows/android.yml is just:

name: Android CI
on:
  push:
    branches: [main, master]
  pull_request:
    branches: [main, master]
jobs:
  build:
    uses: krrishnaaaa/android-ci/.github/workflows/build.yml@main

Eight lines. Same file for every repo. If I need to change the CI logic (upgrade Java version, add lint, add instrumented tests), I change it once in android-ci and all 8 repos pick it up automatically.

Custom configuration

For repos that need different settings, just pass inputs:

jobs:
  build:
    uses: krrishnaaaa/android-ci/.github/workflows/build.yml@main
    with:
      artifact-path: 'app/build/outputs/apk/*/debug/*.apk'  # product flavors

Step 2: Batch Build System Updates

Updating 8 repos manually would take hours. I wrote a shell script that modernizes any Android repo:

#!/bin/bash
set -e

REPO_DIR="$1"
NAMESPACE="$2"
cd "$REPO_DIR"

# settings.gradle.kts
cat > settings.gradle.kts << 'EOF'
pluginManagement {
  repositories {
    google {
      content {
        includeGroupByRegex("com\\.android.*")
        includeGroupByRegex("com\\.google.*")
        includeGroupByRegex("androidx.*")
      }
    }
    mavenCentral()
    gradlePluginPortal()
  }
}
dependencyResolutionManagement {
  repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
  repositories {
    google()
    mavenCentral()
  }
}
rootProject.name = "App"
include(":app")
EOF

# root build.gradle.kts
cat > build.gradle.kts << 'EOF'
plugins {
  alias(libs.plugins.android.application) apply false
}
EOF

# Version catalog
mkdir -p gradle
cat > gradle/libs.versions.toml << 'EOF'
[versions]
agp = "9.1.0"
core-ktx = "1.16.0"
appcompat = "1.7.0"
material = "1.12.0"

[libraries]
core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" }
appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" }

[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
EOF

# app/build.gradle.kts
cat > app/build.gradle.kts << BEOF
plugins {
  alias(libs.plugins.android.application)
}
android {
  namespace = "$NAMESPACE"
  compileSdk = 36
  defaultConfig {
    applicationId = "$NAMESPACE"
    minSdk = 34
    targetSdk = 36
    versionCode = 1
    versionName = "1.0"
  }
  compileOptions {
    sourceCompatibility = JavaVersion.VERSION_17
    targetCompatibility = JavaVersion.VERSION_17
  }
  buildFeatures {
    viewBinding = true
  }
}
dependencies {
  implementation(libs.core.ktx)
  implementation(libs.appcompat)
  implementation(libs.material)
}
BEOF

# Delete old Groovy files
rm -f build.gradle app/build.gradle settings.gradle

echo "Done: $REPO_DIR"

Run it on each repo:

./modernize.sh ~/projects/ListViewDemo "com.example.listviewdemo"
./modernize.sh ~/projects/ChipDemo "com.pcsalt.example.chipdemo"
# ... and so on

The script handles the 80% case. Repos with extra dependencies (navigation, lifecycle) or special config (product flavors) needed manual additions after the script ran.

Step 3: Handle Edge Cases

AGP 9.0+ removes the Kotlin plugin

With AGP 9.0 and above, Kotlin support is built into the Android Gradle Plugin. The org.jetbrains.kotlin.android plugin is no longer needed — and will actually fail the build if you include it.

// Before (AGP 8.x)
plugins {
  id("com.android.application")
  id("org.jetbrains.kotlin.android")  // needed
}

// After (AGP 9.x)
plugins {
  id("com.android.application")  // Kotlin support built in
}

Also remove kotlinOptions — it’s no longer recognized:

// Remove this entire block
kotlinOptions {
  jvmTarget = "17"
}

Eclipse projects need a full rebuild

One repo (ClipboardManager) was still in Eclipse format — project.properties, no Gradle at all. There’s no migration path — just rebuild from scratch. Read the source, understand what it does, create a new Gradle project, rewrite in Kotlin.

Material3 theme names

Theme.Material3.DayNight.DarkActionBar doesn’t exist. If you’re migrating from Theme.AppCompat.Light.DarkActionBar, use Theme.Material3.DayNight instead.

Product flavors change artifact paths

The default CI artifact path app/build/outputs/apk/debug/*.apk doesn’t work when you have product flavors. The actual path becomes app/build/outputs/apk/<flavor>/debug/*.apk. Use a glob:

artifact-path: 'app/build/outputs/apk/*/debug/*.apk'

gradlew must be executable

Old repos have gradlew without execute permission. GitHub Actions runs ./gradlew which fails with exit code 126. Either chmod +x gradlew or copy a fresh one from a working repo.

Results

BeforeAfter
8 repos, 6 different AGP versionsAll on AGP 9.1.0
Mix of Groovy and Kotlin DSLAll Kotlin DSL
1 Eclipse projectModern Gradle
2 Java projectsAll Kotlin
0 repos with CIAll 8 with CI
No version catalogsAll using libs.versions.toml

Every push to main now triggers a build. If a repo breaks, I see it immediately in the GitHub Actions badge.

The Pattern

If you maintain multiple Android repos (sample projects, libraries, client apps), here’s the pattern:

  1. Create a reusable workflow repo — one source of truth for CI logic
  2. Add a one-liner caller to each repo — 8 lines, identical everywhere
  3. Script the repetitive parts — build file generation, dependency updates, theme changes
  4. Handle edge cases manually — product flavors, extra dependencies, source rewrites
  5. Verify with builds — run assembleDebug locally before pushing

The reusable workflow is the real win. One change propagates to all repos. No copy-paste, no drift.

Source

The reusable workflow is open source: krrishnaaaa/android-ci