В данной статье я хочу подробно рассмотреть процесс публикации с нуля Java артефакта через Github Actions в Sonatype Maven Central Repository используя сборщик Gradle.


Данную статью решил написать ввиду отсутствия нормального туториала в одном месте. Всю информацию приходилось собирать по кускам из различных источников, при том, не совсем свежих. Кому интересно, добро пожаловать под кат.


Создание репозитория в Sonatype


Первым этапом нам нужно создать репозиторий в Sonatype Maven Central. Для этого идем сюда, регистрируемся и создаем новую задачу, с просьбой создать нам репозиторий. Вбиваем свой GroupId проекта, Project URL ссылку на проект и SCM url ссылку на систему контроля версий, в которой проект лежит. GroupId здесь должен быть вида com.example, com.example.domain, com.example.testsupport, а также может быть в виде ссылки на ваш гитхаб: github.com/yourusername -> io.github.yourusername. В любом случае, вам нужно будет подтвердить владение данным доменом или профилем. Если вы указали профиль гитхаба, попросят создать публичный репозиторий с нужным именем.


Через некоторое время после подтверждения ваш GroupId будет создан и мы можем перейти к следующему шагу, конфигурации Gradle.


Конфигурируем Gradle


На момент написания статьи я не нашел плагинов для Gradle, которые могли бы помочь с публикацией артефакта. Это единственный плагин, который я нашел, однако автор отказался от его дальнейшей поддержки. Поэтому я решил сделать все самостоятельно, благо сделать это не слишком затруднительно.


Первое, что нужно выяснить, это требования Sonatype для публикации. Они следующие:


  • Наличие исходных кодов и JavaDoc, т.е. должны присутствовать -sources.jar и-javadoc.jar файлы. Как сказано в документации, если нет возможно предоставить исходные коды или документацию, можно сделать пустышку -sources.jar или -javadoc.jar c простым README внутри, чтобы пройти проверку.
  • Все файлы должны быть подписаны с помощью GPG/PGP, и .asc файл, содержащий подпись, должен быть включен для каждого файла.
  • Наличие pom файла
  • Корректные значения groupId, artifactId и version. Версия может быть произвольной строкой и не может заканчиваться -SNAPSHOT
  • Необходимо присутствие name, description и url
  • Присутствие информации о лицензии, разработчиках и системе контроля версий

Это основные правила, которые должны быть соблюдены при публикации. Полная информация доступна здесь.


Реализуем эти требования в build.gradle файле. Для начала добавим всю необходимую информацию о разработчиках, лицензии, системе контроля версий, а также зададим url, имя и описание проекта. Для этого напишем простой метод:


def customizePom(pom) {
    pom.withXml {
        def root = asNode()

        root.dependencies.removeAll { dep ->
            dep.scope == "test"
        }

        root.children().last() + {
            resolveStrategy = DELEGATE_FIRST

            description 'Some description of artifact'
            name 'Artifct name'
            url 'https://github.com/login/projectname'
            organization {
                name 'com.github.login'
                url 'https://github.com/login'
            }
            issueManagement {
                system 'GitHub'
                url 'https://github.com/login/projectname/issues'
            }
            licenses {
                license {
                    name 'The Apache License, Version 2.0'
                    url 'http://www.apache.org/licenses/LICENSE-2.0.txt'
                }
            }
            scm {
                url 'https://github.com/login/projectname'
                connection 'scm:https://github.com/login/projectname.git'
                developerConnection 'scm:git://github.com/login/projectname.git'
            }
            developers {
                developer {
                    id 'dev'
                    name 'DevName'
                    email 'email@dev.ru'
                }
            }
        }
    }
}

Далее нужно указать, чтобы при сборке сгенерировались -sources.jar и-javadoc.jar файлы. Для этого в секцию java нужно добавить следующее:


java {
    withJavadocJar()
    withSourcesJar()
}

Перейдем к последнему требованию, настройке GPG/PGP подписи. Для этого подключим плагин signing:


plugins {
    id 'signing'
}

И добавим секцию :


signing {
    sign publishing.publications
}

Наконец, добавим секцию publishing:


publishing {
    publications {
        mavenJava(MavenPublication) {
            customizePom(pom)
            groupId group
            artifactId archivesBaseName
            version version

            from components.java
        }
    }
    repositories {
        maven {
            url "https://oss.sonatype.org/service/local/staging/deploy/maven2"
            credentials {
                username sonatypeUsername
                password sonatypePassword
            }
        }
    }
}

Здесь sonatypeUsername и sonatypePassword переменные, содержащие логин и пароль, созданные при регистрации на sonatype.org.


Таким образом, финальный build.gradle будет выглядеть следующим образом:


Полный код build.gradle
plugins {
    id 'java'
    id 'maven-publish'
    id 'signing'
}

java {
    sourceCompatibility = JavaVersion.VERSION_1_8
    targetCompatibility = JavaVersion.VERSION_1_8
    withJavadocJar()
    withSourcesJar()
}

group 'io.github.githublogin'
archivesBaseName = 'projectname'
version = System.getenv('RELEASE_VERSION') ?: "0.0.1"

repositories {
    mavenCentral()
}

dependencies {
    testImplementation 'org.junit.jupiter:junit-jupiter-api:5.5.2'
    testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.5.2'
}

test {
    useJUnitPlatform()
}

jar {
    from sourceSets.main.output
    from sourceSets.main.allJava
}

signing {
    sign publishing.publications
}

publishing {
    publications {
        mavenJava(MavenPublication) {
            customizePom(pom)
            groupId group
            artifactId archivesBaseName
            version version

            from components.java
        }
    }
    repositories {
        maven {
            url "https://oss.sonatype.org/service/local/staging/deploy/maven2"
            credentials {
                username sonatypeUsername
                password sonatypePassword
            }
        }
    }
}

def customizePom(pom) {
    pom.withXml {
        def root = asNode()

        root.dependencies.removeAll { dep ->
            dep.scope == "test"
        }

        root.children().last() + {
            resolveStrategy = DELEGATE_FIRST

            description 'Some description of artifact'
            name 'Artifct name'
            url 'https://github.com/login/projectname'
            organization {
                name 'com.github.login'
                url 'https://github.com/githublogin'
            }
            issueManagement {
                system 'GitHub'
                url 'https://github.com/githublogin/projectname/issues'
            }
            licenses {
                license {
                    name 'The Apache License, Version 2.0'
                    url 'http://www.apache.org/licenses/LICENSE-2.0.txt'
                }
            }
            scm {
                url 'https://github.com/githublogin/projectname'
                connection 'scm:https://github.com/githublogin/projectname.git'
                developerConnection 'scm:git://github.com/githublogin/projectname.git'
            }
            developers {
                developer {
                    id 'dev'
                    name 'DevName'
                    email 'email@dev.ru'
                }
            }
        }
    }
}

Хочу заметить, что версию мы получаем из переменной среды: System.getenv('RELEASE_VERSION'). Выставлять ее мы будем при сборке и брать из имени тега.


Генерация PGP ключа


Одно из требований Sonatype это подписание всех файлов с помощью GPG/PGP ключа. Для этого идем сюда и качаем утилиту GnuPG под свою операционную систему.


  • Генерируем ключевую пару: gpg --gen-key, вводим имя пользователя, e-mail, а также задаем пароль.
  • Выясняем id нашего ключа командой: gpg --list-secret-keys --keyid-format short. Id будет указан после слеша, например: rsa2048/9B695056
  • Публикуем публичный ключ на сервер https://keys.openpgp.org командой: gpg --keyserver [https://keys.openpgp.org](https://keys.openpgp.org/) --send-keys 9B695056
  • Экспортируем секретный ключ в произвольное место, он нам понадобится в дальнейшем: gpg --export-secret-key 9B695056 > D:\\gpg\\9B695056.gpg

Настраиваем Github Actions


Перейдем к завершающему этапу, настроим сборку и авто публикацию, используя Github Actions.
Github Actions – функционал, позволяющий автоматизировать рабочий процесс, реализовав полный цикл CI/CD. Сборка, тестирование и деплой могут быть вызваны различными событиями: пушинг кода, создание релиза или issues. Данный функционал абсолютно бесплатен для публичных репозиториев.


В этом разделе я покажу как настроить сборку и пуше кода и деплой в Sonatype репозиторий при выпуске релиза, а также настройку секретов.


Задаем секреты


Для автоматической сборки и деплоя нам понадобится ряд секретных значений, таких как id ключа, пароль, который мы вводили при генерации ключа, непосредственно сам PGP ключ, а также логин/пароль к Sonatype. Задать их можно в специальном разделе в настройках репозитория:



Задаем следующие переменные:


  • SONATYPE_USERNAME/SONATYPE_PASSWORD — логин/пароль, который мы вводили при регистрации в Sonatype
  • SIGNING_KEYID/SIGNING_PASSWORD — id PGP ключа и пароль, установленный при генерации.

На переменной GPG_KEY_CONTENTS хочу остановится поподробнее. Дело в том, что для публикации нам необходим закрытый PGP ключ. Для того, чтобы разместить его в секретах, я воспользовался инструкцией и дополнительно сделал ряд действий.


  • Зашифруем наш ключ с помощью gpg: gpg --symmetric --cipher-algo AES256 9B695056.gpg, введя пароль. Его следует поместить в переменную: SECRET_PASSPHRASE
  • Переведем полученный зашифрованный ключ в текстовый форма с помощью base64: base64 9B695056.gpg.gpg > 9B695056.txt. Содержимое разместим в переменной: GPG_KEY_CONTENTS.

Настройка сборки при пуше кода и создании PR


Для начала нужно создать папку в корне вашего проекта: .github/workflows.


В ней разметить файл, например, gradle-ci-build.yml со следующим содержимым:


name: build

on:
  push:
    branches:
      - master
      - dev
      - testing
  pull_request:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v1
      - name: Set up JDK 8
        uses: actions/setup-java@v1
        with:
          java-version: 8

      - name: Build with Gradle
        uses: eskatos/gradle-command-action@v1
        with:
          gradle-version: current
          arguments: build -PsonatypeUsername=${{secrets.SONATYPE_USERNAME}} -PsonatypePassword=${{secrets.SONATYPE_PASSWORD}}

Данный рабочий процесс будет выполнятся при пуше в ветки master, dev и testing, также при создании пулл реквестов.


В секции jobs указаны шаги, которые должны выполнится по указанным событиям. В данном случае собирать мы будем на последней версии ubuntu, использовать Java 8, а также использовать плагин для Gradle eskatos/gradle-command-action@v1, который используя последнюю версию сборщика запустит команды, указанные в arguments. Переменные secrets.SONATYPE_USERNAME и secrets.SONATYPE_PASSWORD это секреты, которые мы задали ранее.


Результаты сборки будут отражены во вкладке Actions:



Автодеплой при выпуске нового релиза


Для автодеплоя создадим отдельный файл рабочего процесса gradle-ci-publish.yml:


name: publish

on:
  push:
    tags:
      - 'v*'

jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v1
      - name: Set up JDK 8
        uses: actions/setup-java@v1
        with:
          java-version: 8

      - name: Prepare to publish
        run: |
          echo '${{secrets.GPG_KEY_CONTENTS}}' | base64 -d > publish_key.gpg
          gpg --quiet --batch --yes --decrypt --passphrase="${{secrets.SECRET_PASSPHRASE}}"           --output secret.gpg publish_key.gpg
          echo "::set-env name=RELEASE_VERSION::${GITHUB_REF:11}"

      - name: Publish with Gradle
        uses: eskatos/gradle-command-action@v1
        with:
          gradle-version: current
          arguments: test publish -Psigning.secretKeyRingFile=secret.gpg -Psigning.keyId=${{secrets.SIGNING_KEYID}} -Psigning.password=${{secrets.SIGNING_PASSWORD}} -PsonatypeUsername=${{secrets.SONATYPE_USERNAME}} -PsonatypePassword=${{secrets.SONATYPE_PASSWORD}}

Файл практически идентичен предыдущему за исключением события, при котором он будет срабатывать. В данном случае это событие создания тэга с именем, начинающимся на v.


Перед деплоем нам нужно вытянуть PGP ключ из секретов и разместить его в корне проекта, а также расшифровать его. Далее нам нужно выставить специальную переменную среды RELEASE_VERSION к которой мы обращаемся в gradle.build файле. Все это сделано в разделе Prepare to publish. Мы получаем наш ключ из переменной GPG_KEY_CONTENTS, переводим его в gpg файл, затем расшифровываем его, помещая в файл secret.gpg.


Далее мы обращаемся к специальной переменной GITHUB_REF, из которой можем достать версию, которую мы задали при создании тега. Данная переменная в этом случае имеет значение refs/tags/v0.0.2 из которой мы отрезаем первые 11 символов, чтобы достать конкретно версию. Далее стандартно используем команды Gradle для публикации: test publish


Проверка результатов деплоя в Sonatype репозиторий


После создания релиза должен запустится рабочий процесс, описанный в предыдущем разделе. Для этого создаем релиз:



при этом имя тега должно начинаться с v. Если после нажатия Publish release, рабочий процесс успешно отработает, мы можем зайти в Sonatype Nexus чтобы в этом убедиться:



Артефакт появился в Staging репозитории. Сразу он появляется в статусе Open, далее его необходимо вручную перевести в статус Close, нажав соответствующую кнопку. После проверки выполнения всех требований, артефакт переходит в статус Close и более не доступен для изменения. В таком виде он попадет в MavenCentral. Если все хорошо, можно нажать кнопку Release, при этом артефакт попадет в репозиторий Sonatype.


Для того, чтобы артефакт попал в MavenCentral, нужно попросить об этом в задаче, которую мы создали в самом начале. Сделать это нужно только один раз, так мы публикуем в первый раз. В последующие разы это делать не требуется, все будет синхронизироваться автоматически. Включили мне синхронизацию быстро, но чтобы артефакт стал доступен в MavenCentral прошло около 5 дней.


На этом все, мы опубликовали наш артефакт в MavenCentral.


Полезные ссылки


  • Похожая статья, только публикация через maven
  • Staging репозиторий Sonatype
  • Jira Sonatype, в которой необходимо создать задачу
  • Пример репозитория, где это все настроено