Skip to content

Instantly share code, notes, and snippets.

@levibostian
Created June 16, 2021 14:15
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save levibostian/ed2edcaa1ce1722d70683ce83fc429e2 to your computer and use it in GitHub Desktop.
Save levibostian/ed2edcaa1ce1722d70683ce83fc429e2 to your computer and use it in GitHub Desktop.
launch Android library/SDK to maven central

Is your project able to be on Maven Central?

There are requirements in order to be published on Maven Central. If your project is not open source, for example, you cannot be on Maven Central.

Manually apply to get access to Sonatype servers

When you publish Android library files, you do it under a group id. You decide what this group id should be. You could pick your reverse domain name if you own a domain name: com.levibostian. This method requires you verify you own the domain name. You can also just use a github domain name where all you have to do is verify your github repo and your groupId is something like io.github.yourusername.

You must use Sonatype's Jira system in order to reserve your groupId. Here is a full example of the issue template you can use and the communication back and forth through the entire process. (Official docs on using Jira)

Sign

All artifacts uploaded to Maven Central must be signed with GPG to verify the files are actually from you. There are many steps to this. Let's get into it.

  • Create a new master GPG key pair if you do not have one already.
$> gpg2 --full-gen-key

Please select what kind of key you want:
   (1) RSA and RSA (default)
   (2) DSA and Elgamal
   (3) DSA (sign only)
   (4) RSA (sign only)
  (14) Existing key from card
Your selection? 1
RSA keys may be between 1024 and 4096 bits long.
What keysize do you want? (3072) 4096
Requested keysize is 4096 bits
Please specify how long the key should be valid.
         0 = key does not expire
      <n>  = key expires in n days
      <n>w = key expires in n weeks
      <n>m = key expires in n months
      <n>y = key expires in n years
Key is valid for? (0) 0
Key does not expire at all
Is this correct? (y/N) y

GnuPG needs to construct a user ID to identify your key.

Real name: Levi Bostian
Email address: foo@foo.com
Comment:
You selected this USER-ID:
    "Customer.io Android <engineering+android@customer.io>"

Change (N)ame, (C)omment, (E)mail or (O)kay/(Q)uit? O

The command will ask you to enter a passphrase for the key. This is a password to protect your master secret key for extra protection. Use a passphrase!

I like to generate my password with the command openssl rand 60 | openssl base64 -A. Save this passphrase in a safe place.

  • It's recommended that you use subkeys for signing the artifacts. Subkeys are easier to manage in case there is an issue and your master key leaks.

First, we need to get the ID for our master private key. gpg --list-secret-keys --keyid-format long and you will see:

sec   rsa4096/XXXXXXXXXXXXXXXX 2021-05-03 [SC]
      woefjweofjwoiwjoiwjfoiwejiowejfowiefjwoifjweoif
uid                 [ultimate] Your Name <foo@foo.com>
ssb   rsa4096/YYYYYYYYYYYYYYYY 2021-05-03 [E]

The XXXXXXXXXXXXXXXX is the ID we are looking for.

Now, we are going to edit the master private key. This will put us into the gpg program where we will use the command addkey which is the command to add a subkey.

$ gpg --edit-key XXXXXXXXXXXXXXXX
gpg (GnuPG/MacGPG2) 2.2.27; Copyright (C) 2021 Free Software Foundation, Inc.
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.

Secret key is available.

...

gpg> addkey
Please select what kind of key you want:
   (3) DSA (sign only)
   (4) RSA (sign only)
   (5) Elgamal (encrypt only)
   (6) RSA (encrypt only)
  (14) Existing key from card
Your selection? 4
RSA keys may be between 1024 and 4096 bits long.
What keysize do you want? (3072) 4096
Requested keysize is 4096 bits
Please specify how long the key should be valid.
         0 = key does not expire
      <n>  = key expires in n days
      <n>w = key expires in n weeks
      <n>m = key expires in n months
      <n>y = key expires in n years
Key is valid for? (0) 0
Key does not expire at all
Is this correct? (y/N) y
Really create? (y/N) y
We need to generate a lot of random bytes. It is a good idea to perform
some other action (type on the keyboard, move the mouse, utilize the
disks) during the prime generation; this gives the random number
generator a better chance to gain enough entropy.

...

gpg> save

Now, when you run gpg --list-secret-keys --keyid-format long again, you should see a new subkey entry:

ssb   rsa4096/EEEEEEEEEEEE 2021-06-14 [S]

[S] is what we are looking for. That means it's a subkey used for signing which is what we need.

  • Next, we need to send our keys to some keyservers so that others can verify something is signed by us.
$> gpg --keyserver pgp.mit.edu --send-key XXXXXXXXXXXXXXXX
$> gpg --keyserver keyserver.ubuntu.com --send-key XXXXXXXXXXXXXXXX
$> gpg --keyserver keys.openpgp.org  --send-key XXXXXXXXXXXXXXXX

There are many keyservers out there. We are sending the key to a few popular ones above.

  • Now, we need to create a revoke certificate. This certificate is used if your master key gets compromised or you forget your GPG passphrase and you need to notify the public to no longer trust a public key and you can then generate a new master key combo.
gpg --output revoke.asc --gen-revoke XXXXXXXXXXXXXXXX

Save this certificate file in a safe place not to the public.

  • All done with the keys. Now it's time to do Maven specific work for signing. We will use the Maven signing plugin to sign. The files in this project already include what you need to do signing.

Let's look at this code in publish.gradle file:

        // ORG_GRADLE_PROJECT_signingKeyId
        // ORG_GRADLE_PROJECT_signingKey
        // ORG_GRADLE_PROJECT_signingPassword
        useInMemoryPgpKeys(findProperty("signingKeyId"), findProperty("signingKey"), findProperty("signingPassword"))

This is telling the signing plugin what key to use for signing. This is also a CI safe way to sign because we are using environment variables. That means we need to set environment variables on our system. Let's do that:

  1. ORG_GRADLE_PROJECT_signingKeyId - The signing plugin says "The public key ID (The last 8 symbols of the keyId. You can use gpg -K to get it)." The important part there is the last 8 characters of the keyid. Use command gpg --list-secret-keys --keyid-format long and the last 8 characters of the [S] subkey's keyId is the value of this environment variable. So if the key is woijeyrinvnno22o2n, you will set nno22o2n as the value on your CI server.
  2. ORG_GRADLE_PROJECT_signingKey - This is the secret subkey in armor format. On your machine, run gpg --export-secret-subkeys --armor -o /tmp/subkeys.key XXXXXXXXXXXXXXXX. You will enter the passphrase for your master key and then your subkeys will be generated to the output file. Now, you can run cat /tmp/subkeys.key to get the contents of this environment variable. Note: If you don't want to sign with subkeys but instead master key, you would use --export-secret-keys instead of --export-secret-subkeys for the above command but everything else stays the same with the command.
  3. ORG_GRADLE_PROJECT_signingPassword - this is your master key passphrase.

Manually deploy via Sonatype nexus servers web UI

Great. Now you're ready to deploy your library to the Sonatype nexus servers. The publish.gradle file is setup with a release repository and a snapshot repository. Snapshots are only used when the version of your library ends with SNAPSHOT in the name (example 0.1.0-SNAPSHOT or main-SNAPSHOT).

You will need to set the environment variables GRADLE_PUBLISH_USERNAME and GRADLE_PUBLISH_PASSWORD which are the Jira username and password that you created in the steps above.

Directions here are from the official doc. You build and upload your library by using the command MODULE_VERSION=0.1.1-alpha ./gradlew :wendy:uploadArchives where wendy is the name of your module you want to deploy and MODULE_VERSION is the version name of the release. Run this command now to make your first deployment.

After success, you will now login to the nexus repository with your sonatype jira username/password. Now, go to the Staging Repositories tab on the left side. You should see your artifact there!

The rest of this section, I'll guide you with the official docs. Read the remaining sections of the release documentation to get your artifact tested and then uploaded.

Once you have "Released" your artifact, it's time to enable Maven Central sync. You do this by commenting on your Jira ticket that you made about your artifact. Sync should be enabled after that. When the Jira ticket says that sync is enabled for your artifact, you can find your artifact here (Maven central search engines have a few hour delays so this is the best place to see if sync is enabled).

buildscript {
ext.kotlin_version = "1.4.32"
ext {
githubRepoOrg = "levibostian"
githubRepoName = "wendy-android"
developerId = "levibostian"
developerName = "Levi Bostian"
developerEmail = "foo@foo.com"
// these are default values for each module. See sdk/build.gradle for example of how to overwrite values before publishing
modulePackageName = "<overwrite-me>" // example: "com.levibostian.wendy"
moduleName = "<overwrite-me>" // example: wendy"
moduleFormalName = "<overwrite-me>" // example: "Wendy Andriod" // human readable name
moduleDescription = "<overwrite-me>" // example: "Build offline-first mobile apps, easily"
moduleSourceCodeLocalDir = "<overwrite-me>" // For Dokka for source code linking. Path to local source code. Example: "wendy/src/main/java"
moduleSourceCodeLinkUrl = "<overwrite-me>" // For Dokka to link to remote source code. Path to remote source code path that matches local. example: "https://github.com/levibostian/wendy-android/blob/main/wendy/src/main/java"
moduleUrl = "https://github.com/levibostian/wendy-android"
moduleSourceCodeUrl = "https://github.com/levibostian/wendy-android"
moduleSourceCodeConnection = "scm:git@github.com:levibostian/wendy-android.git"
moduleLicenseName = "MIT"
moduleLicenseUrl = "https://github.com/levibostian/wendy-android/blob/main/LICENSE"
}
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:4.2.1'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath "org.jetbrains.dokka:dokka-gradle-plugin:1.4.32"
}
}
allprojects {
repositories {
google()
mavenCentral()
}
}
task clean(type: Delete) {
delete rootProject.buildDir
}
// file in: X/build.gradle where X is name of module (for your library)
plugins {
id 'com.android.library'
id 'kotlin-android'
}
android {
compileSdkVersion 30
buildToolsVersion "30.0.3"
defaultConfig {
minSdkVersion 16
targetSdkVersion 30
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles "consumer-rules.pro"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
testOptions {
unitTests {
// From: http://robolectric.org/getting-started/
includeAndroidResources = true
}
}
}
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
testImplementation 'junit:junit:4.+'
testImplementation "org.robolectric:robolectric:4.5.1"
androidTestImplementation 'androidx.test.ext:junit:1.1.2'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0'
}
// overwrite variables before applying publish.gradle file
ext {
modulePackageName = "com.levibostian.wendy"
moduleName = "core"
moduleFormalName = "Wendy core"
moduleDescription = "build offline first apps"
moduleSourceCodeLocalDir = "wendy/src/main/java"
moduleSourceCodeLinkUrl = "https://github.com/levibostian/wendy-android/blob/main/wendy/src/main/java"
}
apply from: '../gradle/publish.gradle'
// put file in gradle/ directory in your android project
/*
The contents of this file made with help from this guide: https://central.sonatype.org/publish/publish-gradle/
Note, we are using the `maven` plugin instead of `maven-publish` (https://docs.gradle.org/current/userguide/publishing_maven.html)
because maven-publish does not support signing at this time. `maven` works well with `signing`.
I have used `maven-publish` with `signing` and your artifacts don't actually get signed. You know signing happens when
`.asc` files get generated for your maven artifacts after building them.
*/
// apply plugin: 'maven-publish' // maven-publish does not support signing of artifacts so, use `maven`
apply plugin: 'maven'
apply plugin: 'signing'
apply plugin: 'org.jetbrains.dokka'
dokkaHtml.configure {
dokkaSourceSets {
named("main") {
sourceLink {
localDirectory.set(file(moduleSourceCodeLocalDir))
remoteUrl.set(java.net.URL(moduleSourceCodeLinkUrl))
remoteLineSuffix.set("#L") // works for github links
}
}
}
}
def isSnapshotBuild() {
return System.getenv("MODULE_VERSION").contains("SNAPSHOT")
}
afterEvaluate { project ->
uploadArchives {
repositories {
mavenDeployer {
beforeDeployment { deployment -> signing.signPom(deployment) }
pom.groupId = modulePackageName
pom.artifactId = moduleName
pom.version = System.getenv("MODULE_VERSION") // example: 1.0.0 or 1.0.0-alpha
repository(url: "https://s01.oss.sonatype.org/service/local/staging/deploy/maven2/") {
authentication(userName: System.getenv("GRADLE_PUBLISH_USERNAME"), password: System.getenv("GRADLE_PUBLISH_PASSWORD"))
}
snapshotRepository(url: "https://s01.oss.sonatype.org/content/repositories/snapshots/") {
authentication(userName: System.getenv("GRADLE_PUBLISH_USERNAME"), password: System.getenv("GRADLE_PUBLISH_PASSWORD"))
}
pom.project {
name moduleFormalName
packaging "aar"
description moduleDescription
url moduleUrl
scm {
url moduleSourceCodeUrl
connection moduleSourceCodeConnection
developerConnection moduleSourceCodeConnection
}
licenses {
license {
name moduleLicenseName
url moduleLicenseUrl
}
}
developers {
developer {
id developerId
name developerName
email developerEmail
}
}
}
}
}
}
signing {
required { !isSnapshotBuild() && gradle.taskGraph.hasTask("uploadArchives") }
// ORG_GRADLE_PROJECT_signingKeyId
// ORG_GRADLE_PROJECT_signingKey
// ORG_GRADLE_PROJECT_signingPassword
useInMemoryPgpKeys(findProperty("signingKeyId"), findProperty("signingKey"), findProperty("signingPassword"))
sign configurations.archives
}
task sourcesJar(type: Jar) {
group "publishing"
description "Generates sources jar"
archiveClassifier.set("sources")
from android.sourceSets.main.java.srcDirs
}
task javadocJar(type: Jar, dependsOn: dokkaJavadoc) {
group "publishing"
description "Generates javadocJar based on Dokka"
archiveClassifier.set("javadoc")
from dokkaJavadoc.outputDirectory
}
artifacts {
archives javadocJar
archives sourcesJar
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment