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

classes.jar

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

AndroidManifest.xml

The library’s own manifest fragment (permissions, exported components it declares), merged into the consuming app’s manifest at build time.

res/

The library’s resources (layouts, drawables, strings), namespaced under the library’s own R class and merged into the consumer’s resource set.

assets/, jni/

Optional raw assets and native .so libraries, if the library ships any.

proguard.txt (a.k.a. consumer-rules.pro)

ProGuard/R8 rules the library ships for its consumers — see below.

R.txt, public.txt

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 minSdk widens 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 with Build.VERSION.SDK_INT checks and @RequiresApi annotations, increasing its own maintenance cost.

  • A higher minSdk lets 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 it implementation, consumers would get an "unresolved reference" error the moment they tried to call that function, because Gradle deliberately hides implementation dependencies from consumers.

  • api(libs.geometry) means the geometry library’s types are re-exported: any app depending on irurueta-android-glutils automatically gets geometry on its own compile classpath too, because irurueta-android-glutils’s own public API (`CameraToOpenGlHelper, OpenGlToCameraHelper) exposes geometry types 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

name, description, url, at least one license, at least one developer, and a scm block — Maven Central refuses artifacts whose POM cannot answer "who published this and under what license".

A sources JAR

The -sources.jar classifier, so consumers' IDEs can navigate into the library’s source without a separate download.

A Javadoc JAR

The -javadoc.jar classifier (Dokka-generated for a Kotlin library, as above) — required even for a purely Kotlin artifact.

A GPG signature per file

A detached .asc signature for the AAR, POM, sources JAR and Javadoc JAR each, proving the artifact was published by the key’s holder and has not been tampered with since.

Checksums

.md5 and .sha1 files per artifact, generated automatically by the publishing tooling; the Portal recomputes and cross-checks them.

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 manual singleVariant {} + withSourcesJar()/withJavadocJar() block shown earlier with one declarative call that also knows how to wire the result into maven-publish correctly.

  • publishToMavenCentral() configures the Portal as the publishing target and registers the publish* 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 the libraryVersion variable 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/inceptionYear identify the project, licenses {} names the license (Apache License 2.0 here) and where to read it, developers {} credits the maintainer, and scm {} points back at the source repository using the three conventional scm: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

mavenCentralUsername

The Portal user token’s username half.

mavenCentralPassword

The Portal user token’s password half.

signingInMemoryKey

The ASCII-armored GPG private key material (the contents of the exported .asc file above), so signing does not need a local GPG keyring on the build machine — essential for CI runners.

signingInMemoryKeyId

The GPG key’s short id, disambiguating which subkey to use if the exported key has more than one.

signingInMemoryKeyPassword

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

publishToMavenCentral

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.

publishAndReleaseToMavenCentral

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 irurueta-android-glutils’s own `publish.yml workflow runs on a GitHub released event.

--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 (https://jitpack.io/)

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 (google())

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 settings.gradle.kts already declares it alongside mavenCentral(), as shown throughout this section.

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

flowchart LR A["Build library
(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.