En este repo voy a intentar explicar de la forma más sencilla posible la creación de un proyecto de librería para android enfocado a su despliegue en MavenCentral, a fin de que esté disponible en el buscador de paquetes de Android Studio.
JetBrains, como no, proporciona también un tutorial bastante cómodo de seguir (practicamente lo mismo que esto, pero con menos imágenes) https://www.jetbrains.com/help/space/publish-artifacts-to-maven-central.html, aunque es para Spaces
Para ganar tiempo, y dejar el entorno preparado, lo primero que debemos hacer es registrarnos en sonatype.
- Accederemos a la web de incidencias de Sonatype (es un Jira), y nos registraremos como usuario (si no estamos registrados ya).
https://issues.sonatype.org/secure/Dashboard.jspa
- Pulsaremos sobre Signup, y comenzaremos el proceso de registro.
- Una vez acabado el proceso de registro, se nos notifica.
- En el primer inicio de sesión, aparece el asistente típico de Jira, en el que configuraremos varios aspectos del sistema.
- Una vez tenemos todo configurado (Idioma y avatar), nos aparece la página principal para Jira, en la que tenemos pocas opciones para elegir.
- Pulsaremos sobre Crear Incidencia, y seleccionaremos la opción Community Support - Open Source Project Repository Hosting (OSSRH) y como tipo de incidencia, New Project
- Completaremos todos los campos de la incidencia*
Como podemos ver, tenemos un asunto, que será el "título" del proyecto, una breve descripción de lo que vamos a subir y a continuación los campos realmente importantes:
- Group Id: Es la base para los namespaces del proyecto. MUY IMPORTANTE si alojamos nuestro proyecto en GitHub, el package de nuestra librería debe comenzar sí o sí por com.github, en mi caso, establezco com.github.afalabarce
- Project URL: Es la url del proyecto, en mi caso en GitHub.
- SCM url: Es la url para la descarga y clonado del proyecto (normalmente la misma que la de Project URL, terminada en .git).
- Username(s): Será la lista de usuarios (separados por ,) que podrán publicar artifacts en MavenCentral, normalmente, salvo que sea un proyecto colaborativo, seremos nosotros mismos.
- Already Synced to Central: Puesto que es la primera vez que solicitamos creación de un nuevo proyecto, indicaremos que NO.
- Por último, pulsaremos sobre Crear, a fin de que se genere la incidencia y nos creen nuestro nuevo proyecto de alojamiento de artifacts. Nos aparece la información necesaria de la incidencia. Cuando esté solucionada, esto es, nuestro proyecto creado, se nos notificará por email.
Nota Importante: Ha cambiado el criterio para asignaciones de GroupIds para GitHub, se nos notifica, por lo que debemos modificar el GroupId de nuestra incidencia, y crear un repo en nuestra cuenta de GitHub con el código que nos propone el bot. En cuanto lo realicemos, la incidencia pasa a abierta.
Nota Importante II: Gracias a la colaboración de @xavijimenezmulet, tenemos también el criterio para creación de proyectos desde BitBucket. Para la creación de proyectos BitBucket deberemos nombrarlos con el prefijo org.bitbucket..
- Una vez el Bot (sí, un bot, antes lo hacía un humano y tardaba un par de días) valida todo, se marca la incidencia como resuelta y ya podremos acceder a nexus.
Como vemos, el bot nos indica dónde debemos acceder para gestionar nuestros artifacts.
Además se nos proporciona la siguiente url, con información de interés: https://central.sonatype.org/publish/publish-guide/#deployment
Hasta este punto, tenemos la primera fase de la creación de un proyecto de librería disponible para su uso a través de MavenCentral.
La segunda fase consiste en preparar nuestro recien creado proyecto para recibir los aar, POM, etc... Recordemos que MavenCentral está diseñado para Maven (que raro, ¿no?), por lo que tendremos nuestros artifacts, con todo lo que ello implica (POM, Paquetes binarios, Código, Documentación, etc, si procede).
Para comenzar con esta segunda fase, accederemos a la url que nos propone en bot, en mi caso: https://s01.oss.sonatype.org/#welcome
A continuación, los pasos a seguir para preparar nuestro artifact disponible en MavenCentral:
- Hacemos Login, si nos fijamos en la imagen anterior, arriba a la derecha tenemos el enlace Login
- Utilizaremos las mismas credenciales que en la sección anterior.
Importante: Si intentamos iniciar sesión antes de que se nos haya creado nuestro proyecto (la incidencia no se haya dado por finalizada), se mostrará el siguiente error:
-
Puesto que ya tenemos acceso a Nexus, por ahora lo dejaremos aparcado... y, citando a @mouredev, comenzaremos a picar...
-
Debemos crear una firma gpg, ya que será imprescindible para la firma de nuestros artifacts a la hora de subirlos a MavenCentral, para ello vamos a necesitar las utilidades gpg-tools:
- Una vez creadas las claves gpg, las exportaremos a la carpeta de nuestro proyecto, a fin de tenerlas a la mano...
% mkdir gpgKeys
% cd gpgKeys
% gpg --output public.pgp --armor --export afalabarce@gmail.com
% gpg --export-secret-keys -o private.kbx
Los tres siguientes comandos permiten subir nuestra clave a un keyserver (de ubuntu) utilizado por sonatype para validaciones, sin esto, el proceso de publicación fallará. 1234ABCD es el código de clave pública (SHORT, obtenido con el primero de los tres comandos)
% gpg --list-secret-keys --keyid-format SHORT
% gpg --keyserver hkp://pool.sks-keyservers.net --recv-keys 1234ABCD <- sirve para comprobar si la clave existe.
% gpg --keyserver hkp://keyserver.ubuntu.com --send-keys 1234ABCD <- Envía la clave.
Es muy importante (en las capturas dejo la creación "estandar"), crear el fichero de claves privadas con el comando gpg --export-secret-keys -o private.kbx
ya que de lo contrario, Android Studio nos dará constantemente un error de que no encuentra la cabecera pgp.
- Crearemos un nuevo proyecto para Android Studio, puesto que no vamos a necesitar Activities (o sí, dependerá de lo que vayamos a desarrollar) crearemos el proyecto sin actividades. En mi caso, para este tutorial, voy a crear un proyecto para generar un par de Composables que ya había desarrollado en mi vieja cuenta.
- Una vez creado el proyecto, podemos apreciar que Android Studio nos ha creado el proyecto con un módulo de tipo app, esto no nos interesa, por lo que realizaremos las siguientes acciones:
- Agregar un módulo de tipo Librería.
- Eliminar el módulo de tipo app.
Al final, el proyecto se nos debe quedar así...
Como vemos, solo nos queda el proyecto de librería, al que deberemos preparar adecuadamente su build.gradle para las particularidades del proyecto, en mi caso, deberé preparar el build.gradle para que soporte JetpackCompose. En otros casos, se agregarán los implementations necesarios para poder desarrollar la funcionalidad necesaria.
- A continuación, necesitamos preparar tanto el build.gradle del proyecto, como el fichero local.properties (que se excluye del repo git, ya que llevará claves, etc):
- Fichero local.properties. En este fichero guardaremos ciertas variables que nos van a permitir configurar los datos de claves para firma y publicación en sonatype. Muestro el contenido de mi local.properties
## This file is automatically generated by Android Studio.
# Do not modify this file -- YOUR CHANGES WILL BE ERASED!
#
# This file should *NOT* be checked into Version Control Systems,
# as it contains information specific to your local configuration.
#
# Location of the SDK. This is only used by Gradle.
# For customization when using a Version Control System, please read the
# header note.
sdk.dir=/Users/afalabarce/Library/Android/sdk
signing.keyId = 1234ABCD # Clave en hexadecimal de nuestra clave privada GPG, se obtiene con gpg --list-secret-keys --keyid-format SHORT
signing.password = miPasswordGPG
signing.secretKeyRingFile = /Users/afalabarce/Desarrollo/AndroidStudioProjects/JetpackComposeComponents/gpgKeys/private.kbx
ossrhUsername = miUsuarioSonatype
ossrhPassword = miPasswordSonatype
- Fichero build.gradle de proyecto
Hay que agregar un classpath, a fin de que descargue los plugins para publicación maven.
Agregaremos al principio una sección buildScript (si no la tiene)
buildscript {
dependencies {
classpath 'com.github.dcendents:android-maven-gradle-plugin:2.1'
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
}
- Fichero build.gradle para el módulo aar. En este fichero, hay varias secciones a tener muy en cuenta.
En este build.gradle tendremos un poco más de trabajo, ya que hay que añadir algunas cosas más...
En la sección inicial de plugins, deberemos añadir los plugins necesarios para firmar y publicar en maven, quedando la sección como sigue
plugins {
id 'com.android.library'
id 'org.jetbrains.kotlin.android'
id 'maven-publish' // Support for maven publishing artifacts
id 'signing' // Support for signing artifacts
}
Una vez tenemos los plugins preparados, agregaremos lo necesario para que se ejecuten las tareas de firma y publicación. Se ha intentado preparar toda esta sección de código para que no haya que escribir nada y todo proceda de parámetros:
// Settings for publishing at mavenCentral
ext{
publishedGroupId = "io.github.afalabarce"
libraryName = "jetpackcompose"
artifact = "jetpackcompose"
libraryDescription = "Another Project for Jetpack Compose Composable Library"
siteUrl = "https://github.com/afalabarce/jetpackcompose"
gitUrl = "https://github.com/afalabarce/jetpackcompose.git"
libraryVersionId = android.defaultConfig.versionCode
libraryVersionCode = android.defaultConfig.versionName
developerId = "afalabarce"
developerName = "Antonio Fdez. Alabarce"
developerEmail = "afalabarce@gmail.com"
licenseName = "The Apache Software License, Version 2.0"
licenseUrl = "http://www.apache.org/licenses/LICENSE-2.0.txt"
allLicenses = ["Apache-2.0"]
}
task androidSourcesJar(type: Jar) {
archiveClassifier = 'sources'
from android.sourceSets.main.java.source
}
artifacts {
archives androidSourcesJar
}
group = publishedGroupId
version = libraryVersionCode
ext["signing.keyId"] = ''
ext["signing.password"] = ''
ext["signing.secretKeyRingFile"] = ''
ext["ossrhUsername"] = ''
ext["ossrhPassword"] = ''
File secretPropsFile = project.rootProject.file('local.properties')
if (secretPropsFile.exists()) {
println "Found secret props file, loading props"
Properties p = new Properties()
p.load(new FileInputStream(secretPropsFile))
p.each { name, value ->
println "Prop: $name -> $value"
ext[name] = value
}
}
signing {
sign publishing.publications
}
publishing {
publications {
release(MavenPublication) {
// The coordinates of the library, being set from variables that
// we'll set up in a moment
groupId publishedGroupId
artifactId artifact
version libraryVersionCode
println "groupId: $publishedGroupId"
println "Artifact: $artifact"
println "Version: $libraryVersionCode"
// Two artifacts, the `aar` and the sources
artifact("$buildDir/outputs/aar/${project.getName()}-release.aar")
artifact androidSourcesJar
// Self-explanatory metadata for the most part
pom {
name = artifact
description = libraryDescription
// If your project has a dedicated site, use its URL here
url = gitUrl
licenses {
license {
name = licenseName
url = licenseUrl
}
}
developers {
developer {
id = developerId
name = developerName
email = developerEmail
}
}
// Version control info, if you're using GitHub, follow the format as seen here
scm {
connection = 'scm:git:github.com/afalabarce/jetpackcompose.git'
developerConnection = 'scm:git:ssh://github.com/afalabarce/jetpackcompose.git'
url = 'https://github.com/afalabarce/jetpackcompose/tree/master'
}
// A slightly hacky fix so that your POM will include any transitive dependencies
// that your library builds upon
withXml {
def dependenciesNode = asNode().appendNode('dependencies')
project.configurations.implementation.allDependencies.each {
def dependencyNode = dependenciesNode.appendNode('dependency')
dependencyNode.appendNode('groupId', it.group)
dependencyNode.appendNode('artifactId', it.name)
dependencyNode.appendNode('version', it.version)
}
}
}
}
}
repositories {
// The repository to publish to, Sonatype/MavenCentral
maven {
// This is an arbitrary name, you may also use "mavencentral" or
// any other name that's descriptive for you
name = "sonatype"
// these urls depend on the configuration provided to the user by sonatype
def releasesRepoUrl = "https://s01.oss.sonatype.org/service/local/staging/deploy/maven2/"
def snapshotsRepoUrl = "https://s01.oss.sonatype.org/content/repositories/snapshots/"
// You only need this if you want to publish snapshots, otherwise just set the URL
// to the release repo directly
url = version.endsWith('SNAPSHOT') ? snapshotsRepoUrl : releasesRepoUrl
// The username and password we've fetched earlier
credentials {
username ossrhUsername
password ossrhPassword
}
}
}
}
A día de hoy, Google recomienda la utilización de Kotlin DSL para la creación de los ficheros build.gradle, pasando a llamarse build.gradle.kts, la definición del proceso para integración de librerías en MavenCentral cambia bastante cuando trabajamos con ficheros kts, a continuación ponemos un ejemplo de despliegue de una librería en MavenCentral utilizando Kotlin DSL:
import java.io.FileInputStream
import java.util.Properties
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
id ("maven-publish") // Support for maven publishing artifacts
id ("signing") // Support for signing artifacts
}
android {
namespace = "io.github.afalabarce.jetpackcompose.ads.admob"
compileSdk = 33
defaultConfig {
minSdk = 23
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = "11"
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = Constants.composeCompilerVersion
}
}
dependencies {
implementation("androidx.core:core-ktx:${Constants.androidxCoreKtxVersion}")
implementation("androidx.appcompat:appcompat:${Constants.androidxAppCompatVersion}")
implementation ("androidx.lifecycle:lifecycle-runtime-ktx:${Constants.lifeCycleRuntimeVersion}")
implementation ("androidx.lifecycle:lifecycle-runtime-compose:${Constants.lifeCycleRuntimeVersion}")
implementation ("androidx.compose.ui:ui:${Constants.composeVersion}")
implementation ("androidx.compose.ui:ui-tooling:${Constants.composeVersion}")
implementation ("androidx.compose.ui:ui-tooling-preview:${Constants.composeVersion}")
implementation ("androidx.activity:activity-compose:${Constants.activityComposeVersion}")
implementation ("androidx.constraintlayout:constraintlayout-compose:${Constants.constraintLayoutComposeVersion}")
implementation ("androidx.compose.material3:material3:${Constants.composeMaterial3Version}")
// ads dependencies
implementation (platform("com.google.firebase:firebase-bom:${Constants.firebaseBomVersion}"))
implementation ("com.google.firebase:firebase-appcheck-playintegrity")
implementation ("com.google.firebase:firebase-appcheck-debug:${Constants.firebaseAppCheckDebugVersion}")
implementation ("com.google.firebase:firebase-messaging:${Constants.firebaseMessagingVersion}")
implementation ("com.google.android.gms:play-services-ads:${Constants.gmsPlayServicesAdsVersion}")
implementation ("com.google.android.gms:play-services-ads-identifier:${Constants.gmsPlayServicesAdsVersionIdentifierVersion}")
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
}
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
kotlinOptions {
freeCompilerArgs += "-opt-in=kotlin.RequiresOptIn"
}
}
val androidSourcesJarTask by tasks.register("androidSourcesJar", Jar::class.java) {
archiveClassifier.set("sources")
android.sourceSets.toTypedArray().forEach {
println("${it.name}(${it.java.name}) -> ${it.java}")
}
val mainSourceSet = android.sourceSets.first{sourceSets -> sourceSets.name == "main"}
from(mainSourceSet.java.srcDirs).source
}
artifacts {
archives(androidSourcesJarTask)
}
group = SonatypePublishing.publishedGroupId
version = SonatypePublishing.libraryVersionId
val signingData = mutableMapOf(
"signing.keyId" to "",
"signing.password" to "",
"signing.secretKeyRingFile" to "",
"ossrhUsername" to "",
"ossrhPassword" to ""
)
val secretPropsFile = project.rootProject.file("local.properties")
if (secretPropsFile.exists()) {
println("Found secret props file, loading props")
val props = Properties()
props.load(FileInputStream(secretPropsFile))
for ((name, value) in props) {
println("Prop: $name -> $value")
signingData[name.toString()] = value.toString()
}
}
publishing {
publications {
create<MavenPublication>("Maven"){
groupId = SonatypePublishing.publishedGroupId
artifactId = SonatypePublishing.artifact
version = SonatypePublishing.libraryVersionCode
println ("groupId: ${SonatypePublishing.publishedGroupId}")
println ("Artifact: ${SonatypePublishing.artifact}")
println ("Version: ${SonatypePublishing.libraryVersionCode}")
}
withType<MavenPublication>{
println(project.name)
// Two artifacts, the `aar` and the sources
artifact("$buildDir/outputs/aar/${project.name}-release.aar")
artifact(androidSourcesJarTask)
pom {
packaging = "aar"
name.set(SonatypePublishing.artifact)
description.set(SonatypePublishing.libraryDescription)
url.set(SonatypePublishing.gitUrl)
licenses {
license {
name.set(SonatypePublishing.licenseName)
url.set(SonatypePublishing.licenseUrl)
}
}
issueManagement {
system.set("Github")
url.set("https://github.com/afalabarce/jetpackcompose-admob/issues")
}
scm {
connection.set("scm:git:github.com/afalabarce/jetpackcompose-admob.git")
developerConnection.set("scm:git:ssh://github.com/afalabarce/jetpackcompose-admob.git")
url.set("https://github.com/afalabarce/jetpackcompose-admob/tree/master")
}
developers {
developer {
id.set(SonatypePublishing.developerId)
name.set(SonatypePublishing.developerName)
email.set(SonatypePublishing.developerEmail)
}
}
// A slightly hacky fix so that your POM will include any transitive dependencies
// that your library builds upon
withXml {
val dependenciesNode = asNode().appendNode("dependencies")
project.configurations.implementation.configure {
dependencies.forEach { dependency ->
val dependencyNode = dependenciesNode.appendNode("dependency")
dependencyNode.appendNode("groupId", dependency.group)
dependencyNode.appendNode("artifactId", dependency.name)
dependencyNode.appendNode("version", dependency.version)
}
}
}
}
}
}
repositories {
// The repository to publish to, Sonatype/MavenCentral
maven {
// This is an arbitrary name, you may also use "mavencentral" or
// any other name that's descriptive for you
name = "sonatype"
// these urls depend on the configuration provided to the user by sonatype
val releasesRepoUrl = "https://s01.oss.sonatype.org/service/local/staging/deploy/maven2/"
val snapshotsRepoUrl = "https://s01.oss.sonatype.org/content/repositories/snapshots/"
// You only need this if you want to publish snapshots, otherwise just set the URL
// to the release repo directly
setUrl(if (version.toString().endsWith("SNAPSHOT")) snapshotsRepoUrl else releasesRepoUrl)
// The username and password we've fetched earlier
credentials {
username = signingData["ossrhUsername"]
password = signingData["ossrhPassword"]
}
}
}
}
signing {
val keyRingPrivateKey = File(signingData["signing.secretKeyRingFile"].toString()).readText(Charsets.UTF_8)
println("Signing KeyId: ${signingData["signing.keyId"]}")
println("Signing Password: ${signingData["signing.password"]}")
println("Signing Key:\n$keyRingPrivateKey\n---------")
useInMemoryPgpKeys(
signingData["signing.keyId"],
keyRingPrivateKey,
signingData["signing.password"],
)
publishing.publications.forEach { publication ->
println("Signing [${publication.name}] Publication...")
sign(publication)
}
}
Como podemos apreciar, es sustancialmente diferente, queda como ejercicio para el lector revisar y analizar las diferencias ;)
Una vez tenemos todo configurado, ya sólo nos queda publicar en MavenCentral desde las tareas de gradle:
Por último, y una vez gradle nos da el ok a la subida, podremos comprobar en nexus que todo ha ido bien:
En este punto, ya tenemos nuestro aar subido a MavenCentral, pero aún no ha sido publicado.
Para finalizar, debemos guardar el stage del artifact, a fin de que se publique...
Al pulsar sobre close, nos pide una descripción.
Una vez finalizada la publicación, vemos que el estado pasa a Operation in progress, ya solo nos queda esperar.
Y para terminar, una vez todo está correcto, tan sólo debemos publicar la release.
El resultado final, nuestro artifact publicado en el repositorio release de mavenCentral, cuestión de tiempo que aparezca en las búsquedas de librerías de Android Studio. Si tenemos prisa, podemos configurar directamente en build.gradle nuestro recien subido artifact.
Y tras una ardua espera de unas 3/4 horas...
Nuestro aporte a la comunidad está disponible para todos! :)
Espero que este tutorial (con todos los posibles errores - que los tendrá seguro -) sirva a la comunidad para extender y proporcionar trabajos que son de sumo interés para todos
Gracias por leer semejante libreto!! y si crees que he hecho un buen trabajo, puedes invitarme a un café haciendome un PayPalMe! o