The Cost of Manual Releases
Pipeline Architecture
The CI Workflow: Pull Requests
# .github/workflows/ci.yml
name: CI
on:
pull_request:
branches: [main, develop]
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
- uses: gradle/actions/setup-gradle@v4
- name: Run lint
run: ./gradlew lintDebug
- name: Run detekt
run: ./gradlew detekt
- name: Check dependency vulnerabilities
run: ./gradlew dependencyCheckAnalyze
test:
runs-on: ubuntu-latest
needs: validate
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
- uses: gradle/actions/setup-gradle@v4
- name: Run unit tests
run: ./gradlew testDebugUnitTest
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results
path: "**/build/reports/tests/"The Release Workflow: Production Builds
# .github/workflows/release.yml
name: Release
on:
push:
tags: ['v*']
workflow_dispatch:
inputs:
track:
description: 'Play Console track'
required: true
default: 'internal'
type: choice
options: [internal, alpha, beta, production]
jobs:
build-release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
- uses: gradle/actions/setup-gradle@v4
- name: Run all tests
run: ./gradlew testReleaseUnitTest
- name: Build release AAB
run: ./gradlew bundleRelease
env:
KEYSTORE_FILE: ${{ secrets.KEYSTORE_BASE64 }}
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
- name: Upload AAB artifact
uses: actions/upload-artifact@v4
with:
name: release-aab
path: app/build/outputs/bundle/release/*.aab
deploy:
runs-on: ubuntu-latest
needs: build-release
if: startsWith(github.ref, 'refs/tags/v')
steps:
- name: Download AAB
uses: actions/download-artifact@v4
with:
name: release-aab
- name: Upload to Play Console
uses: r0adkll/upload-google-play@v1
with:
serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT }}
packageName: com.example.app
releaseFiles: "*.aab"
track: ${{ inputs.track || 'internal' }}
status: completedSecrets Management
// app/build.gradle.kts
android {
signingConfigs {
create("release") {
// CI: read from environment variables
// Local: read from key.properties file
val keystoreFile = System.getenv("KEYSTORE_FILE")
if (keystoreFile != null) {
// Decode base64 keystore from CI secret
val decoded = Base64.getDecoder().decode(keystoreFile)
val tempFile = File.createTempFile("keystore", ".jks")
tempFile.writeBytes(decoded)
storeFile = tempFile
storePassword = System.getenv("KEYSTORE_PASSWORD")
keyAlias = System.getenv("KEY_ALIAS")
keyPassword = System.getenv("KEY_PASSWORD")
} else {
// Local development: key.properties
val props = Properties().apply {
load(rootProject.file("key.properties")
.inputStream())
}
storeFile = file(props["storeFile"] as String)
storePassword = props["storePassword"] as String
keyAlias = props["keyAlias"] as String
keyPassword = props["keyPassword"] as String
}
}
}
}Gradle Caching for Faster Builds
# gradle.properties
org.gradle.caching=true
org.gradle.configuration-cache=true
org.gradle.parallel=true
org.gradle.jvmargs=-Xmx4g -XX:+UseParallelGCKey Takeaways
- 1A CI/CD pipeline has four stages: validate, test, build, deploy.
- 2Pull request workflows should run in under 10 minutes for developer velocity.
- 3Never commit signing keys -- use GitHub Actions encrypted secrets.
- 4Base64-encode your keystore and decode it during CI builds.
- 5Gradle caching, configuration cache, and parallel builds cut CI times significantly.
- 6Use concurrency groups to cancel redundant workflow runs.
Frequently Asked
How long should an Android pull-request workflow take?
Under 10 minutes. Past that, developers stop waiting for the result and the gate stops functioning. Gradle caching, the configuration cache, and parallel builds are what keep it there.
How do I sign a release build in CI without committing the keystore?
Base64-encode the keystore, store it as a GitHub Actions encrypted secret, and decode it into the runner at build time. Signing keys never enter the repository.
How do I stop redundant CI runs from piling up?
Use concurrency groups. A new push to the same ref cancels the in-flight run instead of queueing another one behind it.
Ready to architect your next Android app?
ANDROID-ARCHITECT generates production-ready Kotlin code, architecture blueprints, and CI/CD configurations from plain-language descriptions. Start building for free.