随着 Jcenter 服务的即将过期,我们需要一个新的仓库来发行我们的SDK.于是我们找上了 MavenCentral,并针对如何在MavenCentral提交包写了这篇文章。
注册账号
前往 sonatype 注册账号,账号注册完毕 后需要提个issue来申请 groupid .
如 OSSRH-66388 所示,如果是GitHub或gitlab下的项目,可以申请 io.github.<yourname>。
如果是域名,则需要在域名的dns解析下添加 TXT 记录,如 OSSRH-66397举例,txt前缀为OSSRH-66397 值为网址https://issues.sonatype.org/browse/OSSRH-66397.
在2021年2月前申请的账号,maven仓库的地址为 https://oss.sonatype.org/,而在之后申请的的地址为 https://s01.oss.sonatype.org/.不过也可以在sonatype上提 issue 来申请变更域名地址,如这个issue所示->OSSRH-66964
Maven Central
注册结束后,可以前往nexus repository查看自己的仓库。在仓库中一般分为两种提交方式,一种是 SNAPSHOT 版本,一种是正式版本。 两者仓库地址不同,分别为:https://s01.oss.sonatype.org/content/repositories/snapshots 和 https://s01.oss.sonatype.org/service/local/staging/deploy/maven2/
SNAPSHOT
SNAPSHOT 版本可以在同一版本号下多次提交,当其他人拉取该版本号时会取最后提交的包,如下图所示

在 SNAPSHOT 仓库中同一个版本下多个包会通过提交日期来区分,这样别人依赖某个版本时就能获得最新更新的包。
同时 SNAPSHOT 不会对提交的包进行 close 操作来对其进行审核,没有一定要有 javadoc、sources、GPG加密等要求。
正式版
提交正式版后包会先进入 Staging Repositories,然后需要在这里依次执行 close 和 release 操作,close操作会验证本次的提交的包是否包含 javadoc、sources、GPG加密文件 和 pom文件的合法等,通过后才能执行 release 操作向官方仓库发布。

开始准备
1. 配置 GPG 私钥
# 生成证书,里面会要求输入用户名和密码,有效期两年。
gpg --gen-key
# 查看公钥
gpg --list-keys
# 查看私钥
gpg --list-secret-keys
# 将证书上传 key server,恩,国内需要代理
gpg --keyserver-options http-proxy=127.0.0.1:1087 --send-keys D81E8990E415EDE0
# 查看8位短id
gpg --list-keys --keyid-format short
# 生成私钥文件,配置到gradle使用
gpg --export-secret-keys -o secring.gpg
# 生成base64文件,后续放入github secrets 中使用
base64 secring.gpg > secring.gpg.b64
具体可参考 gpg配置
2. 本地全局 gradle 配置
找到 .gradle文件夹下gradle.properties文件,如下进行配置
## growingio's config
NEXUS_USERNAME=<sonatype账号>
NEXUS_PASSWORD=<sonatype密码>
signing.keyId=09B66CAF
signing.password=<生成证书时的输入密码>
signing.secretKeyRingFile=<生成私钥文件的文件路径>
当然在项目下的gradle.properties也同样生效。
3. 配置 pom 文件
在根项目下的 gradle.properties 添加以下配置说明,这些说明将会一一填入到 pom 文件中
GROUP=com.growingio.android
POM_DESCRIPTION=GrowingIO Android SDK Library.
POM_DEVELOPER_EMAIL=sdk-integration@growingio.com
POM_URL=https://github.com/growingio/growingio-sdk-android-autotracker
POM_SCM_URL=https://github.com/growingio/growingio-sdk-android-autotracker
POM_SCM_CONNECTION=scm:git@github.com:growingio/growingio-sdk-android-autotracker.git
POM_SCM_DEV_CONNECTION=scm:git@github.com:growingio/growingio-sdk-android-autotracker.git
POM_LICENCE_NAME=The Apache Software License, Version 2.0
POM_LICENCE_URL=http://www.apache.org/licenses/LICENSE-2.0.txt
POM_LICENCE_DIST=repo
POM_DEVELOPER_ID=GrowingIo Sdk
POM_DEVELOPER_NAME=GrowingIo Sdk
4. 编写gradle的maven上传代码
sonatype 的gradle教程 已经过期好久了,这里推荐在 Github 上的gradle-mvn-push。后面的所有改动都基于在这个文件版本上得来的,具体如何改动在后续说明。
示例代码 publishMaven.gradle:
/*
 * Copyright 2021 Cpacm
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 *
 *
 * Based on: https://github.com/mcxiaoke/gradle-mvn-push/blob/master/gradle-mvn-push.gradle.
 *
 * To install in a local maven repo:
 * 1. In the project you want to test, add mavenLocal() to the repositories list.
 * 2. In Project, run: ./gradlew publishToMavenLocal
 *
 * For faster runs add: -x check when building.
 */
apply plugin: 'maven-publish'
apply plugin: 'signing'
version = releaseConfiguration.releaseVersion
group = GROUP
@SuppressWarnings("GrMethodMayBeStatic")
def isReleaseBuild() {
    return !version.contains("SNAPSHOT")
}
def getReleaseRepositoryUrl() {
    return hasProperty('RELEASE_REPOSITORY_URL') ? RELEASE_REPOSITORY_URL
            : 'https://s01.oss.sonatype.org/service/local/staging/deploy/maven2/'
}
def getSnapshotRepositoryUrl() {
    return hasProperty('SNAPSHOT_REPOSITORY_URL') ? SNAPSHOT_REPOSITORY_URL
            : 'https://s01.oss.sonatype.org/content/repositories/snapshots/'
}
def getRepositoryUsername() {
    return hasProperty('USERNAME') ? USERNAME : (hasProperty('NEXUS_USERNAME') ? NEXUS_USERNAME : '')
}
def getRepositoryPassword() {
    return hasProperty('PASSWORD') ? PASSWORD : (hasProperty('NEXUS_PASSWORD') ? NEXUS_PASSWORD : '')
}
def configurePom(pom) {
    pom.name = POM_NAME
    pom.packaging = POM_PACKAGING
    pom.description = POM_DESCRIPTION
    pom.url = POM_URL
    pom.scm {
        url = POM_SCM_URL
        connection = POM_SCM_CONNECTION
        developerConnection = POM_SCM_DEV_CONNECTION
    }
    pom.licenses {
        license {
            name = POM_LICENCE_NAME
            url = POM_LICENCE_URL
            distribution = POM_LICENCE_DIST
        }
    }
    pom.developers {
        developer {
            id = POM_DEVELOPER_ID
            name = POM_DEVELOPER_NAME
            email = POM_DEVELOPER_EMAIL
        }
    }
}
afterEvaluate { project ->
    def isAndroidProject = project.plugins.hasPlugin('com.android.application') || project.plugins.hasPlugin('com.android.library')
    publishing {
        repositories {
            maven {
                def releasesRepoUrl = getReleaseRepositoryUrl()
                def snapshotsRepoUrl = getSnapshotRepositoryUrl()
                url = isReleaseBuild() ? releasesRepoUrl : snapshotsRepoUrl
                credentials(PasswordCredentials) {
                    username = getRepositoryUsername()
                    password = getRepositoryPassword()
                }
            }
        }
    }
    if (isAndroidProject) {
        task androidJavadocs(type: Javadoc, dependsOn: assembleDebug) {
            source = android.sourceSets.main.java.source
            classpath += project.files(android.getBootClasspath().join(File.pathSeparator))
            // include BuildConfig.java
            classpath += project.files(project.file("build/generated/source/buildConfig/debug"))
            excludes = ['**/*.kt']
        }
        task androidJavadocsJar(type: Jar, dependsOn: androidJavadocs) {
            classifier = 'javadoc'
            from androidJavadocs.destinationDir
        }
        task androidSourcesJar(type: Jar) {
            classifier = 'sources'
            from android.sourceSets.main.java.source
        }
        android.libraryVariants.all { variant ->
            tasks.androidJavadocs.doFirst {
                classpath += files(variant.javaCompileProvider.get().classpath.files.join(File.pathSeparator))
            }
        }
    } else {
        task sourcesJar(type: Jar, dependsOn: classes) {
            classifier = 'sources'
            from sourceSets.main.allSource
        }
        task javadocsJar(type: Jar, dependsOn: javadoc) {
            classifier = 'javadoc'
            from javadoc.destinationDir
        }
        artifacts {
            archives sourcesJar
            archives javadocsJar
        }
    }
    if (JavaVersion.current().isJava8Compatible()) {
        allprojects {
            tasks.withType(Javadoc) {
                options.addStringOption('Xdoclint:none', '-quiet')
            }
        }
    }
    if (JavaVersion.current().isJava9Compatible()) {
        allprojects {
            tasks.withType(Javadoc) {
                options.addBooleanOption('html5', true)
            }
        }
    }
    artifacts {
        if (isAndroidProject) {
            archives androidSourcesJar
            archives androidJavadocsJar
            archives project.tasks.bundleDebugAar
        }
    }
    publishing {
        publications {
            mavenAgent(MavenPublication) {
                groupId GROUP
                artifactId POM_ARTIFACT_ID
                version version
                configurePom(pom)
                if (isAndroidProject) {
                    artifact bundleReleaseAar
                    artifact androidSourcesJar
                    pom.withXml {
                        def dependenciesNode = asNode().appendNode('dependencies')
                        project.configurations.all { configuration ->
                            def name = configuration.name
                            // api will duplicate with implementation
                            if (name == 'releaseImplementation' || name == 'implementation') {
                                configuration.allDependencies.each {
                                    if (it.name != "unspecified" && it.version != "unspecified") {
                                        def groupId = it.group
                                        def artifactId = it.name
                                        if (it instanceof ProjectDependency) {
                                            def properties = it.getDependencyProject().getProperties()
                                            groupId = properties.get("GROUP")
                                            artifactId = properties.get("POM_ARTIFACT_ID")
                                        }
                                        println "dependencies:" + groupId + ":" + artifactId + ":" + it.version
                                        def dependencyNode = dependenciesNode.appendNode('dependency')
                                        dependencyNode.appendNode('groupId', groupId)
                                        dependencyNode.appendNode('artifactId', artifactId)
                                        dependencyNode.appendNode('version', it.version)
                                        dependencyNode.appendNode('scope', 'compile')
                                    }
                                }
                            }
                        }
                    }
                } else {
                    from components.java
                    artifact sourcesJar
                    artifact javadocsJar
                }
            }
            if (project.plugins.hasPlugin('java-gradle-plugin')) {
                pluginMaven(MavenPublication) {
                    groupId GROUP
                    artifactId POM_ARTIFACT_ID
                    version version
                    configurePom(pom)
                }
            }
        }
    }
}
signing {
    required { isReleaseBuild() && gradle.taskGraph.hasTask("publish") }
    publishing.publications.all { publication ->
        sign publication
    }
}
基本使用:在需要发布的项目下添加 gradle.properties文件,并注明 ARTIFACT_ID名,如下:
POM_NAME=autotracker
POM_ARTIFACT_ID=autotracker
POM_PACKAGING=aar
POM_DESCRIPTION=GrowingIO Android SDK AutoTracker.
同时在 build.gradle 中引入该文件
apply from: "${rootProject.projectDir}/gradle/publishMaven.gradle"
# 上传到本地仓库
./gradlew publishToMavenLocal
# 上传到远程仓库
./gradlew publish
5. Github Ci脚本
GitHub上有提供 Action 这一工具,该工具可以在线上进行各种操作:比如编译,测试,校验等,所以打包上传也是可以的。
- 配置 Github Secrets
 

各个配置分别对应 全局 gradle 配置 步骤中的各个参数,其中 SIGNING_SECRET_KEY_RING_FILE 是秘钥文件经过 base64 后产生的字符串。
- 
自动 close 和 release 这里直接使用 gradle-nexus-staging-plugin的插件。它能通过 sonatype 的api来自动实现
Staging Repositories上的close和release操作 - 
完整ci脚本
 
name: Publish Maven
on:
  release:
    types: [published]
jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - name: Set up JDK 1.8
        uses: actions/setup-java@v1
        with:
          java-version: 1.8
      # Gradle 缓存配置
      - name: Cache Gradle packages
        uses: actions/cache@v2
        with:
          path: ~/.gradle/caches
          key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle') }}
          restore-keys: ${{ runner.os }}-gradle
      # 给 gradlew 文件授权
      - name: Grant Permission to Execute
        run: |
          chmod +x gradlew
          chmod +x gradle/publishAllToMavenLocal.sh
      # 构建项目
      - name: Build with Gradle
        run: |
          bash ./gradle/publishAllToMavenLocal.sh
      # 解码秘钥
      - name: Decode
        run: |
          echo "${{secrets.SIGNING_SECRET_KEY_RING_FILE}}" > ~/.gradle/secring.gpg.b64
          base64 -d ~/.gradle/secring.gpg.b64 > ~/.gradle/secring.gpg
      - name: Publish to Sonatype
        env:
          NEXUS_USERNAME: ${{ secrets.NEXUS_USERNAME }}
          NEXUS_PASSWORD: ${{ secrets.NEXUS_PASSWORD }}
        run: ./gradlew publish -PNEXUS_USERNAME="${NEXUS_USERNAME}" -PNEXUS_PASSWORD="${NEXUS_PASSWORD}" -Psigning.keyId=${{secrets.SIGNING_KEY_ID}} -Psigning.password=${{secrets.SIGNING_PASSWORD}} -Psigning.secretKeyRingFile=$(echo ~/.gradle/secring.gpg)
      - name: Close And Release Package
        env:
          NEXUS_USERNAME: ${{ secrets.NEXUS_USERNAME }}
          NEXUS_PASSWORD: ${{ secrets.NEXUS_PASSWORD }}
        run: ./gradlew tryCloseAndReleaseRepository -PNEXUS_USERNAME="${NEXUS_USERNAME}" -PNEXUS_PASSWORD="${NEXUS_PASSWORD}"
至此,一套完整的自动打包系统就搭建完成了。
参考资料:
附录:关于在 GrowingIO 各个仓库上遇到的几个问题
gradle plugin 插件打包
plugin项目应该属于 java 项目,但在 publications下它会单独走 pluginMaven,所以需要对其做特别处理来规范其 ARTIFACT_ID
if (project.plugins.hasPlugin('java-gradle-plugin')) {
    pluginMaven(MavenPublication) {
        groupId GROUP
        artifactId POM_ARTIFACT_ID
        version version
        configurePom(pom)
    }
}
javadoc找不到BuildConfig文件
这里需要在生成 javadoc 包的时候添加 BuildConfig类的路径到 classpath 中。
task androidJavadocs(type: Javadoc, dependsOn: assembleDebug) {
    source = android.sourceSets.main.java.source
    classpath += project.files(android.getBootClasspath().join(File.pathSeparator))
    // include BuildConfig.java
    classpath += project.files(project.file("build/generated/source/buildConfig/debug"))
    excludes = ['**/*.kt']
}
当然可能不同版本的gradle生成的地址不一样,请自己测试并添加
关于 uploadArchives
旧版本 Gradle 的task,依赖于插件 maven。现在一般都是
apply plugin: 'maven-publish'
afterEvaluate { project ->
    publishing {}
}
且在 gradle 版本 5.0后,需要将 publishing 放置在 afterEvaluate 中。
关于依赖
api依赖和implementation依赖会重复出现在 project.configurations.all循环中(api > implementation),所以只需要判断 implementation即可
pom.withXml {
    def dependenciesNode = asNode().appendNode('dependencies')
    project.configurations.all { configuration ->
        def name = configuration.name
        // api will duplicate with implementation
        if (name == 'releaseImplementation' || name == 'implementation') {
            configuration.allDependencies.each {
                if (it.name != "unspecified" && it.version != "unspecified") {
                    def groupId = it.group
                    def artifactId = it.name
                    if (it instanceof ProjectDependency) {
                        def properties = it.getDependencyProject().getProperties()
                        groupId = properties.get("GROUP")
                        artifactId = properties.get("POM_ARTIFACT_ID")
                    }
                    println "dependencies:" + groupId + ":" + artifactId + ":" + it.version
                    def dependencyNode = dependenciesNode.appendNode('dependency')
                    dependencyNode.appendNode('groupId', groupId)
                    dependencyNode.appendNode('artifactId', artifactId)
                    dependencyNode.appendNode('version', it.version)
                    dependencyNode.appendNode('scope', 'compile')
                }
            }
        }
    }
}