Tutorial de despliegue a MavenCentral / Deploy to MavenCentral Tutorial

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.

  1. 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

image

  1. Pulsaremos sobre Signup, y comenzaremos el proceso de registro.

image

  1. Una vez acabado el proceso de registro, se nos notifica.

image

  1. En el primer inicio de sesión, aparece el asistente típico de Jira, en el que configuraremos varios aspectos del sistema.

image

  1. Una vez tenemos todo configurado (Idioma y avatar), nos aparece la página principal para Jira, en la que tenemos pocas opciones para elegir.

image

  1. Pulsaremos sobre Crear Incidencia, y seleccionaremos la opción Community Support - Open Source Project Repository Hosting (OSSRH) y como tipo de incidencia, New Project

image

  1. Completaremos todos los campos de la incidencia*

image

image

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.
  1. 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.

image

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. image

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..

  1. 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.

image

Como vemos, el bot nos indica dónde debemos acceder para gestionar nuestros artifacts. image

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

image

A continuación, los pasos a seguir para preparar nuestro artifact disponible en MavenCentral:

  1. Hacemos Login, si nos fijamos en la imagen anterior, arriba a la derecha tenemos el enlace Login

image

  1. Utilizaremos las mismas credenciales que en la sección anterior.

image

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: image

  1. Puesto que ya tenemos acceso a Nexus, por ahora lo dejaremos aparcado... y, citando a @mouredev, comenzaremos a picar...

  2. 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:

image

image

image

image

image

  1. Una vez creadas las claves gpg, las exportaremos a la carpeta de nuestro proyecto, a fin de tenerlas a la mano...

image

image

image

 % 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.kbxya que de lo contrario, Android Studio nos dará constantemente un error de que no encuentra la cabecera pgp.

  1. 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.

image

image

  1. 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.

image

Al final, el proyecto se nos debe quedar así... image

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.

  1. 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
            }
        }
    }
}

NOTA:

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: image

Por último, y una vez gradle nos da el ok a la subida, podremos comprobar en nexus que todo ha ido bien:

image

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... image

Al pulsar sobre close, nos pide una descripción. image

Una vez finalizada la publicación, vemos que el estado pasa a Operation in progress, ya solo nos queda esperar. image

Y para terminar, una vez todo está correcto, tan sólo debemos publicar la release. image

image

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.

image

Y tras una ardua espera de unas 3/4 horas... image

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 ko-fi