Building and Publishing Android Libraries to Maven Central
|
This content was generated with the assistance of AI and should be verified against the official documentation of Android before being relied on in production. See the section bibliography for the reference material consulted while preparing these pages. |
Most of this section builds TrailMate, an application. This page instead covers the other shape of Android
project: a library module that other Gradle projects consume as a dependency, and the path from that module
to a published artifact resolvable from Maven Central as implementation("group:artifact:version"). It uses
com.irurueta:irurueta-android-glutils, a real, published Kotlin library
(https://github.com/albertoirurueta/irurueta-android-glutils), as its worked example throughout, because its
actual build.gradle.kts is a complete, correct reference for the publishing configuration this page explains.
Gradle fundamentals (version catalogs, the android {} DSL, dependency configurations in general) are covered in
Project Structure, Gradle and Build Variants and are not
repeated here; Dokka’s role in a CI pipeline that also publishes documentation to GitHub Pages is covered in full
in CI/CD with GitHub Actions.
Library Modules vs. Application Modules
An Android library module applies the com.android.library plugin instead of com.android.application. The two
plugins share almost the entire android {} DSL, but a library module has no applicationId, produces an
AAR (Android Archive) instead of an APK/AAB, and is meant to be depended on rather than installed:
// lib/build.gradle.kts
plugins {
alias(libs.plugins.android.library)
}
android {
namespace = "com.irurueta.android.glutils"
compileSdk = 36
defaultConfig {
minSdk = 26
testOptions.targetSdk = 36
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
}
What’s Inside an AAR
An AAR is a zip archive, structurally similar to an APK but not installable on its own:
| Entry | Contents |
|---|---|
|
Compiled Kotlin/Java bytecode for the library’s own code (not its dependencies — those stay as separate POM entries, resolved transitively by the consumer’s build). |
|
The library’s own manifest fragment (permissions, exported components it declares), merged into the consuming app’s manifest at build time. |
|
The library’s resources (layouts, drawables, strings), namespaced under the library’s own |
|
Optional raw assets and native |
|
ProGuard/R8 rules the library ships for its consumers — see below. |
|
The library’s resource symbol table, used to keep resource IDs stable across the library’s own build and any consumer that shrinks resources. |
Unlike an application, a library module is compiled once and consumed by many different projects, each with its
own compileSdk, Kotlin version and dependency graph — which is why the choices below (API surface, minSdk,
ProGuard rules) matter more here than they do for :app.
namespace and minSdk Trade-offs
namespace plays the same role as in an application module — it is the package under which the library’s own
R/BuildConfig classes are generated — but for a library it is also effectively part of the published
artifact’s identity: consumers reference the library’s generated resources through this namespace
(com.irurueta.android.glutils.R.drawable.foo), so renaming it after a release is a breaking change.
minSdk on a library is a floor that every consumer inherits: Gradle takes the highest minSdk across an
app and all of its library dependencies as the effective minSdk for the whole build, so a library that sets
minSdk = 26 silently raises the floor for any app that depends on it, even if that app’s own minSdk is lower.
This creates a direct trade-off:
-
A lower
minSdkwidens the pool of apps that can adopt the library, but forces the library’s own code to either avoid newer platform APIs or guard every use withBuild.VERSION.SDK_INTchecks and@RequiresApiannotations, increasing its own maintenance cost. -
A higher
minSdklets the library use modern APIs directly and keeps its code simpler, at the cost of locking out apps that still support older devices.
irurueta-android-glutils picks minSdk = 26 (Android 8.0), a common floor for general-purpose libraries in
2026 that balances device coverage against API modernity. A library author should document the chosen minSdk
prominently, since it is invisible in the published artifact’s coordinates and only surfaces as a Gradle manifest
merger error at the consumer’s build time if violated.
api vs. implementation on a Library’s Public Surface
The choice between the api and implementation dependency configurations, introduced in
Project Structure, Gradle and Build Variants for
intra-project modules, has sharper consequences once the module is a published library, because the boundary
now crosses a network, not just a Gradle project graph:
// lib/build.gradle.kts (dependencies block)
dependencies {
implementation(libs.material) // internal use only -- not exposed to consumers
api(libs.geometry) // types from this artifact appear in the library's own public API
}
-
implementation(libs.material)means Material Components classes are usable inside the library’s own code, but do not appear on the compile classpath of any app that depends on this library — if the library used a Material class in a public function signature while declaring itimplementation, consumers would get an "unresolved reference" error the moment they tried to call that function, because Gradle deliberately hidesimplementationdependencies from consumers. -
api(libs.geometry)means thegeometrylibrary’s types are re-exported: any app depending onirurueta-android-glutilsautomatically getsgeometryon its own compile classpath too, becauseirurueta-android-glutils’s own public API (`CameraToOpenGlHelper,OpenGlToCameraHelper) exposesgeometrytypes directly.
The rule for a library author is mechanical: if a dependency’s types appear in a public/open function
signature, property type, or supertype that consumers can see, it must be api; everything else should default
to implementation to keep the library’s own dependency changes from forcing every consumer to recompile against
a new transitive version.
consumer-rules.pro
An application’s own proguard-rules.pro only shapes how R8 shrinks and obfuscates that app’s code. A library
cannot predict how it will be minified inside someone else’s app, so it ships its own rule file that R8
automatically merges into the consuming app’s shrink pass:
// lib/build.gradle.kts
android {
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
defaultConfig {
consumerProguardFiles("consumer-rules.pro")
}
}
# lib/consumer-rules.pro
-keep class com.irurueta.android.glutils.CameraToOpenGlHelper { *; }
-keepclassmembers class com.irurueta.android.glutils.** { native <methods>; }
A library typically leaves its own isMinifyEnabled = false (there is little to shrink in a single module, and
shrinking would risk stripping symbols consumers still need), while consumer-rules.pro keeps the public API
surface, any class accessed through reflection or JNI, and anything Parcelable/Serializable that R8 might
otherwise remove or rename inside the app that eventually ships the code. See
Android Developers — Create an Android Library
and Building, Signing and Preparing a Release for R8 and
shrinking in general.
Explicit API Mode
Kotlin’s explicit API mode turns a library’s implicit visibility and return-type inference into compile
errors, forcing every public/protected declaration to state its visibility and return type explicitly — it
exists specifically for library authors, because an accidentally public function or an inferred return type that
silently narrows on a later Kotlin version becomes a breaking change for consumers the moment it ships:
// lib/build.gradle.kts
kotlin {
explicitApi() // or explicitApi(ExplicitApiMode.Warning) to warn instead of fail the build
}
// lib/src/main/kotlin/com/irurueta/android/glutils/GLTextureView.kt
// rejected under explicitApi(): no visibility modifier, no explicit return type
fun currentRenderMode() = renderMode
// accepted: visibility and return type both explicit
public fun currentRenderMode(): Int = renderMode
A companion practice for larger libraries is binary-compatibility validation
(org.jetbrains.kotlinx.binary-compatibility-validator), a Gradle plugin that dumps the library’s public ABI to
a checked-in .api file and fails the build if a later change alters that ABI without the file being
deliberately updated — catching, for example, an accidental signature change that would break every consumer
recompiling against the new version without a major version bump. See
Kotlin — Explicit API Mode for
Library Authors and Kotlin — Binary Compatibility
Validator.
Publication Basics: singleVariant and Dokka
Before reaching Maven Central at all, a library needs a Gradle publication — a named bundle of the compiled
AAR, its sources and its documentation that maven-publish (Gradle’s built-in publishing plugin) or a wrapper
around it knows how to upload. AGP exposes this natively for library modules through the publishing {} block:
// lib/build.gradle.kts
android {
publishing {
singleVariant("release") {
withSourcesJar()
withJavadocJar()
}
}
}
publishing {
publications {
register<MavenPublication>("release") {
groupId = "com.irurueta"
artifactId = "irurueta-android-glutils"
version = "1.1.11"
afterEvaluate {
from(components["release"])
}
}
}
repositories {
maven {
name = "internal"
url = uri("https://repo.example.com/internal-releases")
credentials {
username = providers.gradleProperty("internalRepoUsername").orNull
password = providers.gradleProperty("internalRepoPassword").orNull
}
}
}
}
singleVariant("release") tells AGP which build variant to publish (a library typically ships only one), while
withSourcesJar()/withJavadocJar() attach a -sources.jar and a -javadoc.jar alongside the AAR — Maven Central requires both, as covered below. This raw maven-publish path is the right tool for publishing to
a private or internal Maven repository (an organization’s own Artifactory/Nexus, or the maven { url = … }
repository shown above); publishing to Maven Central itself adds enough additional requirements (namespace
verification, GPG signing, a specific POM shape) that in practice almost nobody drives it through raw
maven-publish, and instead uses a wrapper plugin such as vanniktech’s, covered next. See
Android Developers — Configure
Publishing Variants and Android Developers — Upload Your Library to a Maven Repository.
Javadoc for a Kotlin library is generated by Dokka, not the javadoc tool, since Kotlin’s Java-interop
signatures and KDoc comments need a Kotlin-aware generator:
// lib/build.gradle.kts
plugins {
alias(libs.plugins.dokka)
}
irurueta-android-glutils runs ./gradlew :lib:dokkaGenerate in CI and deploys the resulting HTML straight to
GitHub Pages with crazy-max/ghaction-github-pages, giving the library a browsable API reference site alongside
its Maven Central artifact. That CI workflow, and how Dokka’s Javadoc JAR feeds into publishJavadocJar below,
is covered in CI/CD with GitHub Actions; this page only notes
that the same Dokka output backs both the published -javadoc.jar and the public documentation site.
Publishing to Maven Central via the Central Publisher Portal
Maven Central is the default repository every Gradle/Maven build resolves from unless told otherwise, and
publishing to it used to go through OSSRH (the OSS Repository Hosting Nexus instance at
oss.sonatype.org). OSSRH was shut down on June 30, 2025; all publishing now goes through the Central
Publisher Portal (https://central.sonatype.com/), a rewritten publishing pipeline with its own account
system, tokens and validation rules, replacing the old Nexus staging-repository workflow entirely. See
Central Sonatype — OSSRH End-of-Life and
Central Sonatype — Central Publisher Portal.
Namespace Verification and User Tokens
Before publishing anything, an account at the Portal must verify a namespace — typically a reversed domain
the publisher controls (com.irurueta) or, for a com.github./io.github. namespace, ownership proven
through a GitHub-hosted verification file. Once verified, the Portal issues a user token (a username/password
pair scoped to publishing, distinct from the account’s own login credentials) used to authenticate uploads.
What the Portal Requires of Every Published Artifact
The Portal validates every component before allowing it to reach Maven Central, rejecting an upload that is missing any of the following:
| Requirement | Why |
|---|---|
A complete POM |
|
A sources JAR |
The |
A Javadoc JAR |
The |
A GPG signature per file |
A detached |
Checksums |
|
Generating and Exporting a GPG Key
Signing needs a GPG key pair. The key is generated once, locally, and never committed to a repository:
# generate a new key pair interactively (RSA, 4096 bits recommended)
gpg --full-generate-key
# list keys to find the key id to export
gpg --list-secret-keys --keyid-format LONG
# publish the public key so Maven Central's signature checkers can verify it
gpg --keyserver keyserver.ubuntu.com --send-keys <KEY_ID>
# export the private key, ASCII-armored, for in-memory signing (vanniktech's signingInMemoryKey)
gpg --export-secret-keys --armor <KEY_ID> > private-key.asc
The exported private-key.asc file’s contents are the actual secret — this page never shows real key material,
and neither should any committed file or CI log. It is pasted once into a CI secret store (see below) or a local,
never-committed ~/.gradle/gradle.properties, and the local .asc file is deleted afterward. See
Central Sonatype — Working with PGP Signatures.
The vanniktech Gradle Maven Publish Plugin
com.vanniktech.maven.publish (version 0.37.0) wraps maven-publish, AGP’s singleVariant publication,
Dokka’s Javadoc JAR and GPG signing into one declarative block, and is the tool irurueta-android-glutils
actually uses. Its real lib/build.gradle.kts is reproduced below, abridged: the testOptions {} and
packaging {} blocks, the sonar {} block and most test dependencies are left out, since they don’t affect
publishing. Everything this section discusses is shown as it is in the repository, plus one explanatory comment.
One line needs context first. glutils’s version catalog pins AGP 9.0.1. Since AGP 9.0, Kotlin support is
built into the Android plugins, and applying org.jetbrains.kotlin.android as well fails the build (either
"plugin is no longer required" or a duplicate kotlin extension error). glutils still applies alias(libs.plugins.kotlin.android) only because its
root gradle.properties opts out of both AGP 9 defaults:
# gradle.properties (irurueta-android-glutils) -- temporary AGP 9 opt-outs
android.builtInKotlin=false
android.newDsl=false
android.newDsl=false has to accompany android.builtInKotlin=false, because the standalone kotlin-android
plugin isn’t compatible with AGP 9’s new DSL. Both are migration escape hatches: the build prints a warning
while they are set, and AGP 10 removes them. A new library on the AGP 9.4.1 baseline used throughout this section
should leave both properties at their defaults and drop the kotlin.android alias from plugins {}; the
top-level kotlin { compilerOptions { … } } block then configures AGP’s built-in Kotlin instead.
// lib/build.gradle.kts (irurueta-android-glutils, real published library; abridged)
import com.vanniktech.maven.publish.AndroidSingleVariantLibrary
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android) // needs android.builtInKotlin=false on AGP 9; drop it in a new library
alias(libs.plugins.dokka)
alias(libs.plugins.sonarqube)
alias(libs.plugins.publish)
}
val libraryVersion = "1.1.11"
android {
namespace = "com.irurueta.android.glutils"
compileSdk = 36
defaultConfig {
minSdk = 26
testOptions.targetSdk = 36
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
debug {
enableUnitTestCoverage = true
enableAndroidTestCoverage = true
}
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildFeatures {
buildConfig = true
}
}
kotlin {
compilerOptions {
jvmTarget = JvmTarget.JVM_17
}
}
dependencies {
implementation(libs.material)
api(libs.geometry)
testImplementation(libs.junit)
// ... test deps
}
mavenPublishing {
configure(AndroidSingleVariantLibrary(
variant = "release",
sourcesJar = true,
publishJavadocJar = true,
))
publishToMavenCentral()
signAllPublications()
coordinates("com.irurueta", "irurueta-android-glutils", libraryVersion)
pom {
name.set("irurueta-android-glutils")
description.set("OpenGL utilities for Android")
inceptionYear.set("2025")
url.set("https://github.com/albertoirurueta/irurueta-android-glutils/")
licenses {
license {
name.set("Apache License 2.0")
url.set("https://github.com/albertoirurueta/irurueta-android-glutils/blob/main/LICENSE")
distribution.set("https://github.com/albertoirurueta/irurueta-android-glutils/blob/main/LICENSE")
}
}
developers {
developer {
id.set("albertoirurueta")
name.set("Alberto Irurueta")
email.set("alberto@irurueta.com")
url.set("https://github.com/albertoirurueta/")
}
}
scm {
url.set("https://github.com/albertoirurueta/irurueta-android-glutils/")
connection.set("scm:git:github.com/albertoirurueta/irurueta-android-glutils.git")
developerConnection.set("scm:git:ssh://github.com/albertoirurueta/irurueta-android-glutils.git")
}
}
}
Piece by piece:
-
AndroidSingleVariantLibrary(variant = "release", sourcesJar = true, publishJavadocJar = true)tells the plugin which AGP variant to publish and to attach both the sources and Javadoc (Dokka-generated) jars — replacing the manualsingleVariant {}+withSourcesJar()/withJavadocJar()block shown earlier with one declarative call that also knows how to wire the result intomaven-publishcorrectly. -
publishToMavenCentral()configures the Portal as the publishing target and registers thepublish*tasks that upload to it, reading credentials from the properties covered next. -
signAllPublications()attaches a GPG signature to every artifact in the publication (AAR, POM, sources jar, Javadoc jar) using the in-memory key properties, so no artifact reaches the plugin’s upload step unsigned. -
coordinates("com.irurueta", "irurueta-android-glutils", libraryVersion)sets the group, artifact id and version that consumers will eventually depend on — this is the single source of truth for the published coordinates, read from thelibraryVersionvariable declared at the top of the file rather than duplicated. -
The
pom {}block supplies exactly the fields the Portal’s validation checks for:name/description/url/inceptionYearidentify the project,licenses {}names the license (Apache License 2.0 here) and where to read it,developers {}credits the maintainer, andscm {}points back at the source repository using the three conventionalscm:git:URL forms (browsable, read-only clone, and authenticated push).
Credentials and Signing Properties
The plugin reads five Gradle properties, never hard-coded in the build script:
| Property | Holds |
|---|---|
|
The Portal user token’s username half. |
|
The Portal user token’s password half. |
|
The ASCII-armored GPG private key material (the contents of the exported |
|
The GPG key’s short id, disambiguating which subkey to use if the exported key has more than one. |
|
The passphrase protecting the private key, if one was set when the key was generated. |
Locally, these go into ~/.gradle/gradle.properties — outside the project directory, so they are never at risk
of being committed:
# ~/.gradle/gradle.properties (never inside the project repository)
mavenCentralUsername=
mavenCentralPassword=
signingInMemoryKey=
signingInMemoryKeyId=
signingInMemoryKeyPassword=
In CI, Gradle picks up any environment variable named ORG_GRADLE_PROJECT_<propertyName> as if it were that
Gradle property, so a workflow supplies the same five values from its own secret store without ever writing them
to a file in the checkout:
# .github/workflows/publish.yml (excerpt)
- name: Publish to Maven Central
run: ./gradlew lib:publishAndReleaseToMavenCentral --no-configuration-cache
env:
ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.MAVEN_CENTRAL_USERNAME }}
ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.MAVEN_CENTRAL_PASSWORD }}
ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_MEMORY_KEY }}
ORG_GRADLE_PROJECT_signingInMemoryKeyId: ${{ secrets.SIGNING_MEMORY_KEY_ID }}
ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_IN_MEMORY_KEY_PASSWORD }}
The secret names on the right are the ones glutils’s real publish.yml uses — SIGNING_MEMORY_KEY and
SIGNING_MEMORY_KEY_ID, but SIGNING_IN_MEMORY_KEY_PASSWORD — and the
CI/CD with GitHub Actions page uses the same five. A secret’s
name is arbitrary, but it must match the name the repository secret was created under, character for character;
a mismatched name resolves to an empty string and the upload fails at the signing step.
None of these five values should ever appear literally in a build script, a committed gradle.properties, or a
workflow file — only property/environment-variable names and secrets.<NAME> references, exactly as shown
above. The full anatomy of this workflow (triggers, test/lint steps that run before publishing, coverage and
SonarQube) belongs to CI/CD with GitHub Actions. See
vanniktech — Publishing to Maven Central and
vanniktech — Signing >
In-Memory Signing.
publishToMavenCentral vs. publishAndReleaseToMavenCentral
The vanniktech plugin exposes two Gradle tasks that both upload artifacts to the Portal, differing only in what happens after the upload succeeds:
| Task | Behavior |
|---|---|
|
Uploads the signed artifacts and leaves the resulting deployment in the Portal’s "pending" state, requiring a manual "Publish" click (or a separate Portal API call) before it becomes visible on Maven Central. Useful when a human should review the deployment’s validation results before it goes live. |
|
Uploads, waits for the Portal’s automatic validation to pass, and releases the deployment immediately, with no
manual step — the shape needed for a fully automated release pipeline, and what |
--no-configuration-cache is passed alongside publishAndReleaseToMavenCentral in practice because the
plugin’s Portal-polling behavior (waiting for validation before releasing) is not yet configuration-cache safe — the same configuration-cache constraints introduced in
Project Structure, Gradle and Build Variants for the AGP
variant API.
Publishing SNAPSHOT Versions
A version ending in -SNAPSHOT (for example 1.2.0-SNAPSHOT) publishes to Maven Central’s separate snapshot
repository instead of the release repository — snapshots are mutable (re-publishing the same snapshot version
overwrites it) and meant for consumers actively developing against an unreleased version, resolved by adding the
snapshot repository explicitly:
// settings.gradle.kts (a consumer wanting to try a SNAPSHOT build)
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
maven("https://central.sonatype.com/repository/maven-snapshots/")
}
}
publishToMavenCentral()/signAllPublications() work unchanged for a SNAPSHOT coordinates(…) version; the
Portal itself routes the upload based on the version string’s suffix, with no manual-release step for snapshots
(they are always immediately resolvable, and always mutable).
Consuming the Published Library
Once a release is live on Maven Central (typically within minutes of publishAndReleaseToMavenCentral
completing, or after the manual release step for publishToMavenCentral), any project resolving from
mavenCentral() — already the default in a project scaffolded as shown in
Project Structure, Gradle and Build Variants — can add it
like any other dependency:
// app/build.gradle.kts (a consumer of irurueta-android-glutils)
dependencies {
implementation("com.irurueta:irurueta-android-glutils:1.1.11")
}
No special repository declaration is needed beyond the standard mavenCentral() entry, since the Central
Publisher Portal is the publishing front end for the same Maven Central repository every Gradle build already
resolves from by default.
Alternatives to Maven Central
Maven Central is the default choice for a public, general-purpose library, but three alternatives remain common for narrower cases:
| Repository | When it fits |
|---|---|
JitPack ( |
Builds and serves a package directly from a GitHub tag or commit with no publishing step at all — convenient for a quick, low-friction release of a small or internal library, at the cost of resolving from a third-party build service rather than Maven Central’s own infrastructure. |
GitHub Packages |
Hosts Maven artifacts per-repository on GitHub itself; consumers must authenticate even for public read access (a GitHub token), which makes it a poor fit for a library meant for wide, frictionless adoption, but a reasonable choice for artifacts an organization only shares among its own private repositories. |
Google’s Maven repository ( |
Not a publishing target for third-party libraries — it is where Google itself publishes AndroidX/Jetpack and
Play Services artifacts. Mentioned here only because every consumer’s |
JCenter, Bintray’s old default Maven repository, was shut down in 2021 and is unreachable; any project still
referencing jcenter() in settings.gradle.kts or build.gradle.kts must remove it and resolve exclusively
from google() and mavenCentral().
Figure: the Publishing Flow
(assembleRelease,
AAR + sources jar
+ Dokka javadoc jar)"] --> B["Sign artifacts
(signAllPublications,
in-memory GPG key)"] B --> C["Publish to
Central Publisher Portal
(publishToMavenCentral /
publishAndReleaseToMavenCentral)"] C --> D{"Manual or
automated release?"} D -->|"manual click"| E["Portal validation,
then human clicks
'Publish'"] D -->|"automated"| F["Portal validation,
released immediately"] E --> G["Artifact live on
Maven Central"] F --> G G --> H["Consumers resolve
implementation('group:artifact:version')
from mavenCentral()"]
The two release paths converge once the Portal’s own validation (POM completeness, signatures, checksums, all
covered above) passes — publishToMavenCentral only adds a human decision point before the same artifact
becomes resolvable, while publishAndReleaseToMavenCentral removes that pause entirely for a fully hands-off
release pipeline.