Shared build configuration for the Android apps. Sibling to
go-config and
js-config, consumed the same way: as a git
submodule under packages/.
Public on purpose. Nothing here is secret, and a public submodule needs no token to clone.
libs.versions.toml the single version catalog
build-logic/ the convention plugin
.github/workflows/android-app.yml the reusable build/release workflow
sample/ a minimal app that proves the plugin works
git submodule add https://github.com/mountEvarus/android-config.git packages/android-configmobile/settings.gradle.kts:
pluginManagement {
includeBuild("../packages/android-config/build-logic")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
versionCatalogs {
create("libs") { from(files("../packages/android-config/libs.versions.toml")) }
}
}
rootProject.name = "<app>"
include(":app")There is no mobile/build.gradle.kts at all. The convention plugin puts AGP and the Kotlin
plugins on the classpath itself, so there is no root build file and no
plugins { ... apply false } block to maintain.
mobile/app/build.gradle.kts carries dependencies and nothing else:
plugins {
id("dev.evanhynes.android.app")
}
dependencies {
implementation(platform(libs.compose.bom))
implementation(libs.compose.ui)
implementation(libs.compose.material3)
implementation(libs.activity.compose)
}mobile/gradle.properties names the app, which sets the applicationId
(dev.evanhynes.<name>) and the default signing key alias:
android.app.name=fooRequired, with no fallback, and the build fails with an explanatory message if it is missing. Deriving it from the directory name would let a repo rename silently change the applicationId, and to Android a different applicationId is a different app: the update would install alongside the old one rather than replacing it, stranding its data.
For an existing app the value must match the applicationId already published. It becomes a
package segment, so it must be lowercase letters and digits, starting with a letter, which
is why long-name uses longname.
mobile/version.properties holds the hand-bumped part of the version:
VERSION_MAJOR=1
VERSION_MINOR=0.github/workflows/android.yml:
name: Android
on:
push:
branches: [main]
paths: ['mobile/**', 'packages/android-config', '.github/workflows/android.yml']
pull_request:
paths: ['mobile/**', 'packages/android-config', '.github/workflows/android.yml']
workflow_dispatch:
concurrency:
group: android-${{ github.ref }}
cancel-in-progress: true
jobs:
android:
uses: mountEvarus/android-config/.github/workflows/android-app.yml@main
with:
app-name: foo
release-title: Foo (Android)
secrets: inheritApplied by id("dev.evanhynes.android.app"):
- AGP, the Compose compiler plugin and ktlint
namespaceandapplicationIdasdev.evanhynes.<app-name>compileSdk,targetSdk,minSdkfrom the catalogversionName/versionCodefromversion.propertiesplus the CI run number- the release signing config, from
keystore.propertiesorRELEASE_KEYSTORE_* - JDK 17 source/target and jvmTarget
- Android Lint with
abortOnError(errors fail the build, warnings are reported) - unit tests with Android resources and default return values enabled
An app still declares anything genuinely its own: buildConfigField,
manifestPlaceholders, extra plugins (ksp, hilt, kotlin-serialization), and a minSdk
override where a dependency forces one.
Room's schema directory stays with the app, because it needs the KSP plugin and Gradle's embedded Kotlin is too old to compile against KSP's current metadata:
ksp { arg("room.schemaLocation", "$projectDir/schemas") }Note the submodule path has no /**. A submodule bump changes the gitlink path itself
(:160000 160000 abc def M packages/android-config), and a /** pattern needs a segment
beneath it, so it would never match: the app would take a catalog update without ever
building or releasing.
The workflow initialises only packages/android-config, not every submodule. A consuming
repo may also carry private submodules for its other surfaces, and submodules: recursive would
fail trying to clone those with the default token. The Android build never reads them, so it does
not ask for them, and no PAT is needed anywhere in this pipeline.
The workflow writes mobile/local.properties before building, from repo-level settings:
| Source | Key |
|---|---|
vars.API_BASE_URL |
api.base.url |
secrets.DEVICE_API_KEY |
device.api.key |
secrets.GOOGLE_MAPS_API_KEY |
google.maps.api.key |
Only keys that are set get written, so an app that does not use one falls back to its build
default. Apps read them the usual way and expose what they need as buildConfigField:
val localProperties = Properties().apply {
rootProject.file("local.properties").takeIf { it.exists() }?.inputStream()?.use(::load)
}
// ...
buildConfigField("String", "API_BASE_URL", "\"${localProperties.getProperty("api.base.url", "http://10.0.2.2:8080/")}\"")The default is the Android emulator's host loopback, so a local API works out of the box.
Resolved in order: keystore.properties at the Gradle root, then the RELEASE_KEYSTORE_*
environment variables, then nothing, which produces an unsigned APK.
Blank counts as missing. An unset GitHub secret arrives as an empty string rather than an absent variable, so without that check a forgotten secret yields a broken signing config instead of a clean fallback.
Repo secrets for a release: RELEASE_KEYSTORE_BASE64, RELEASE_KEYSTORE_PASSWORD,
RELEASE_KEY_ALIAS, RELEASE_KEY_PASSWORD.
AGP 9 needs JDK 17. A machine whose default java is newer will fail before Gradle
starts, with a message about the Gradle daemon rather than about the JDK, so it is worth
checking first:
JAVA_HOME=$(/usr/libexec/java_home -v 17) ANDROID_HOME=$HOME/Library/Android/sdk ./gradlew assembleDebugOn macOS, brew install openjdk@17 if /usr/libexec/java_home -v 17 finds nothing.
sample/ is a minimal Compose app wired to the convention plugin exactly as a real app is.
CI builds it on every push. It exists so a change here cannot silently break four apps that
would only find out on their next submodule bump.
cd sample && ./gradlew ktlintCheck lintDebug testDebugUnitTest assembleDebugOn AGP 9 and Gradle 9. AGP 8.13 was the original target, since it reaches compileSdk 36
without the AGP 9 migration, but current AndroidX has moved past it: Compose BOM 2026.08.00
pulls runtime-saveable 1.12.0, which requires AGP 9.1+ and compileSdk 37. Pinning a stale
Compose to stay on 8.x was the worse trade.
compileSdk is 37 so the apps compile against the newest APIs. targetSdk stays 36, so
nothing opts into Android 17 runtime behaviour changes until something needs them.
Renovate is configured in renovate.json and scoped deliberately:
"enabledManagers": ["gradle", "gradle-wrapper", "github-actions"]enabledManagers is a whitelist, so nothing else is ever picked up. Updates arrive in
three groups rather than a stream:
| Group | Contents |
|---|---|
| android build toolchain | AGP, Kotlin, KSP, ktlint, and the Gradle distribution |
| androidx | everything under androidx.* |
| github actions | the SHA-pinned actions in the reusable workflow |
The toolchain is one group on purpose: AGP, Kotlin, KSP and Gradle have to stay mutually compatible, so a half-applied bump is worse than no bump.
Daily, overnight: "schedule": ["before 6am"] with "timezone": "Europe/London", so PRs
are waiting in the morning rather than arriving mid-task. The timezone is set explicitly
because Renovate defaults to UTC, which drifts against local time across DST.
Weekly was the first attempt and was wrong: this repo exists to hold dependency versions, so a seven-day window leaves a stale toolchain unnoticed for most of it.
Rate limiting is separate from the schedule. Renovate's defaults cap new PRs at 2 per hour,
and prConcurrentLimit: 3 caps open PRs at 3, so the steady state is a few PRs refreshed
as they are merged.
Because every consuming app takes its versions from this catalog, one merged PR here is the update for all of them. Each app then picks it up by bumping its submodule pointer.