The Cost of Manual Releases
Pipeline Architecture
How Should the Pull-Request Workflow Run?
concurrency group allows at most one running job or workflow in the group at any time, and cancel-in-progress: true cancels the run already in flight when a new commit arrives, so a second push does not leave two builds racing.
timeout-minutes caps a hung job. Without it, a job on a GitHub-hosted runner can run for up to 6 hours, and a stuck emulator will use all of it.# .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
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
- uses: actions/setup-java@v6
with:
distribution: temurin
java-version: 17
- uses: gradle/actions/setup-gradle@v6
- name: Run lint
run: ./gradlew lintDebug
- name: Run detekt
run: ./gradlew detekt
- name: Check dependency vulnerabilities
run: ./gradlew dependencyCheckAnalyze
test:
runs-on: ubuntu-latest
timeout-minutes: 15
needs: validate
steps:
- uses: actions/checkout@v7
- uses: actions/setup-java@v6
with:
distribution: temurin
java-version: 17
- uses: gradle/actions/setup-gradle@v6
- name: Run unit tests
run: ./gradlew testDebugUnitTest
- name: Upload test results
if: always()
uses: actions/upload-artifact@v7
with:
name: test-results
path: "**/build/reports/tests/"The Release Workflow: Production Builds
track input is deprecated in favour of tracks, which takes a comma-separated list of Play tracks. And status accepts completed, inProgress, halted or draft: pair inProgress with a userFraction between 0.0 and 1.0 to release to a share of users instead of all of them.# .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
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
- uses: actions/setup-java@v6
with:
distribution: temurin
java-version: 17
- uses: gradle/actions/setup-gradle@v6
- 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@v7
with:
name: release-aab
path: app/build/outputs/bundle/release/*.aab
deploy:
runs-on: ubuntu-latest
timeout-minutes: 15
needs: build-release
if: startsWith(github.ref, 'refs/tags/v')
steps:
- name: Download AAB
uses: actions/download-artifact@v8
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"
tracks: ${{ inputs.track || 'internal' }}
status: completedSecrets Management
base64 -w 0 release.jks > release.base64 on Linux, then gh secret set KEYSTORE_BASE64 < release.base64. Secrets are limited to 48 KB each, so check the encoded size if your keystore holds several keys. Store the Play Console service account JSON as a second secret.
Your build.gradle.kts should read signing configuration from environment variables, with a fallback to a local key.properties for developer machines. The rest of the hardening checklist is in our Android security guide.// 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/actions/setup-gradle action caches Gradle state between runs without configuration; the settings below go further.
- Enable the build cache in gradle.properties so task outputs are reused
- Enable the configuration cache, which stores the result of the configuration phase and lets Gradle skip that phase when its inputs have not changed
- Use concurrency to cancel redundant runs when new commits push to the same branch
Our Gradle build optimization guide covers the local side of the same problem.# gradle.properties
org.gradle.caching=true
org.gradle.configuration-cache=true
org.gradle.parallel=true
org.gradle.jvmargs=-Xmx4g -XX:+UseParallelGCWhat Does the Pipeline Cost?
| Runner | Per-minute rate | A 10-minute job |
|---|---|---|
| Linux 2-core (x64) | $0.006 | $0.06 |
| Windows 2-core (x64) | $0.010 | $0.10 |
| macOS 3-core or 4-core | $0.062 | $0.62 |
Key Takeaways
- 1A CI/CD pipeline has four stages: validate, test, build, deploy.
- 2Aim for a pull-request workflow under 10 minutes, and set timeout-minutes: a hosted job can otherwise run for up to 6 hours.
- 3Never commit signing keys. Use GitHub Actions encrypted secrets, which are limited to 48 KB each.
- 4Base64-encode your keystore and decode it during CI builds.
- 5Upload with tracks, not the deprecated track input; status inProgress with a userFraction makes it a staged rollout.
- 6Linux runners bill at $0.006 a minute against $0.062 for macOS. Build Android on Linux.
- 7Use concurrency groups to cancel redundant workflow runs.
Frequently Asked
How long should an Android pull-request workflow take?
Aim for under 10 minutes. Past that, developers stop waiting for the result and the gate stops functioning. Gradle's build cache, the configuration cache and parallel execution keep it there, and timeout-minutes stops a hung emulator from running to GitHub's 6-hour job limit while everyone assumes the build is still working.
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 on the runner at build time. Secrets are limited to 48 KB each, so check the encoded size. Read the passwords and the key alias from secrets too, and keep a local key.properties fallback so developer machines never need the CI values.
How do I stop redundant CI runs from piling up?
Use a concurrency group keyed on the branch or pull request. GitHub allows at most one running job or workflow per group, and cancel-in-progress: true cancels the run already in flight when a new commit arrives. You stop paying for builds of commits that are no longer the head of the branch.
How much does GitHub Actions cost for Android builds?
Hosted runners bill by the minute, rounded up per job. A standard Linux 2-core runner is $0.006 a minute, so a 10-minute build costs about six cents. macOS runners are $0.062 a minute, roughly ten times more, which is why an Android build, unlike an iOS build, should run on Linux.
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.