Migrate maven publish with publishing by using the Portal Publisher API

pull/7482/head
John Niang 2025-05-29 11:44:26 +08:00
parent 981d6d1c4a
commit 033764ea63
No known key found for this signature in database
GPG Key ID: D7363C015BBCAA59
3 changed files with 243 additions and 14 deletions

View File

@ -22,6 +22,18 @@ java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
withJavadocJar()
withSourcesJar()
}
jar {
manifest {
attributes(
'Implementation-Title': project.name,
'Implementation-Version': project.version,
'Implementation-Vendor': 'Halo Project',
)
}
}
repositories {
@ -99,11 +111,6 @@ dependencies {
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
java {
withSourcesJar()
withJavadocJar()
}
publishing {
publications.named('mavenJava', MavenPublication) {
from components.java

View File

@ -0,0 +1,200 @@
import groovy.json.JsonSlurper
import org.gradle.api.DefaultTask
import org.gradle.api.artifacts.repositories.PasswordCredentials
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.provider.Property
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.InputFile
import org.gradle.api.tasks.Internal
import org.gradle.api.tasks.Optional
import org.gradle.api.tasks.TaskAction
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.time.Duration
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
import static java.net.http.HttpRequest.BodyPublishers.ofFile
import static java.net.http.HttpRequest.BodyPublishers.ofString
import static java.nio.charset.StandardCharsets.UTF_8
/**
* Task to upload a bundle to Sonatype Central Repository.
* @author JohnNiang
*/
abstract class UploadBundleTask extends DefaultTask {
@InputFile
abstract RegularFileProperty getBundleFile();
@Input
@Optional
abstract Property<PublishingType> getPublishingType();
@Input
abstract Property<PasswordCredentials> getCredentials();
UploadBundleTask() {
getPublishingType().convention(PublishingType.AUTOMATIC)
}
@TaskAction
void upload() {
if (project.version.toString().endsWith("-SNAPSHOT")) {
logger.lifecycle("Detected SNAPSHOT version, uploading to maven-snapshots repository...")
uploadSnapshotBundle()
logger.lifecycle("Snapshot bundle uploaded successfully.")
return
}
def deploymentId = uploadReleaseBundle()
def maxWait = Duration.ofMinutes(12)
def pollingInterval = Duration.ofSeconds(10)
def maxRetry = (int) (maxWait.toMillis() / pollingInterval.toMillis())
def retry = 0
logger.lifecycle("Preparing to check deployment state for deployment ID: ${deploymentId}, max retries: ${maxRetry}, polling interval: ${pollingInterval.toSeconds()} seconds")
while (retry++ <= maxRetry) {
def state = checkDeploymentState(deploymentId)
if (state == null) {
throw new RuntimeException("Failed to check deployment state for deployment ID: ${deploymentId}")
}
if (state == DeploymentState.PUBLISHED) {
println("Bundle published successfully with deployment ID: ${deploymentId}")
break
}
if (state == DeploymentState.VALIDATED) {
println("Bundle validated successfully with deployment ID: ${deploymentId}")
break
}
if (state == DeploymentState.FAILED) {
throw new RuntimeException("Bundle deployment failed with deployment ID: ${deploymentId}")
}
if (state == DeploymentState.VALIDATING) {
logger.lifecycle('Bundle is being validated, please wait...')
} else if (state == DeploymentState.PENDING) {
logger.lifecycle('Bundle is pending, please wait...')
}
println("Deployment state: ${state}, retrying(${retry}) in ${pollingInterval.toSeconds()} seconds...")
sleep(pollingInterval.toMillis())
}
}
DeploymentState checkDeploymentState(String deploymentId) {
createHttpClient().withCloseable { client ->
def endpoint = "https://central.sonatype.com/api/v1/publisher/status?id=${deploymentId}"
def request = HttpRequest.newBuilder(URI.create(endpoint))
.header("Authorization", bearerAuthorizationHeader)
.POST(HttpRequest.BodyPublishers.noBody())
.build()
def response = client.send(request, HttpResponse.BodyHandlers.ofString(UTF_8))
if (response.statusCode() != 200) {
throw new RuntimeException("Failed to check deployment status: status: ${response.statusCode()} - body: ${response.body()}")
}
logger.debug("Response body: ${response.body()}")
def status = new JsonSlurper().parseText(response.body())
logger.debug("Deployment status: ${status}")
return status.properties.deploymentState as DeploymentState
}
}
void uploadSnapshotBundle() {
logger.lifecycle('Starting to upload snapshot bundle: {}', bundleFile.asFile.get())
createHttpClient().withCloseable { client ->
new ZipInputStream(bundleFile.asFile.get().newInputStream()).withCloseable { zis ->
ZipEntry entry
while ((entry = zis.nextEntry) != null) {
if (entry.directory) {
continue
}
def relativePath = entry.name
def endpoint = "https://central.sonatype.com/repository/maven-snapshots/$relativePath"
def request = HttpRequest.newBuilder(URI.create(endpoint))
.header("Authorization", basicAuthorizationHeader)
.header("Content-Type", "application/octet-stream")
.PUT(HttpRequest.BodyPublishers.ofByteArray(zis.readAllBytes()))
.build()
def response = client.send(request, HttpResponse.BodyHandlers.ofString(UTF_8))
logger.debug('Response status code: {}, body: {}', response.statusCode(), response.body())
if (response.statusCode() != 200 && response.statusCode() != 201) {
throw new RuntimeException("Failed to upload snapshot bundle: status: ${response.statusCode()} - entry: ${relativePath} - body : ${response.body()}")
}
logger.lifecycle('Uploaded snapshot entry: {}, status: {}', relativePath, response.statusCode())
}
}
}
}
String uploadReleaseBundle() {
logger.lifecycle('Starting to upload release bundle: {}', bundleFile.asFile.get())
createHttpClient().withCloseable { client ->
// See https://www.w3.org/Protocols/rfc1341/7_2_Multipart.html for more
def boundary = "------haloformboundary${UUID.randomUUID().toString().replace('-', '')}"
def crlf = "\r\n"
def delimiter = "${crlf}--${boundary}"
def endpoint = "https://central.sonatype.com/api/v1/publisher/upload?publishingType=${publishingType}"
def publishers = new ArrayList<HttpRequest.BodyPublisher>()
publishers.add(ofString("${delimiter}${crlf}"))
publishers.add(ofString("""\
Content-Disposition: form-data; name="bundle"; filename="${bundleFile.get().asFile.name}"\
${crlf}\
"""))
publishers.add(ofString("Content-Type: application/octet-stream${crlf}${crlf}"))
publishers.add(ofFile(bundleFile.get().asFile.toPath()))
publishers.add(ofString("${delimiter}--${crlf}"))
def request = HttpRequest.newBuilder(URI.create(endpoint))
.header("Authorization", bearerAuthorizationHeader)
.header("Content-Type", "multipart/form-data; boundary=${boundary}")
.POST(HttpRequest.BodyPublishers.concat(publishers.toArray(HttpRequest.BodyPublisher[]::new)))
.build()
def response = client.send(request, HttpResponse.BodyHandlers.ofString(UTF_8))
if (logger.debugEnabled) {
logger.debug('Response status code: {}, body: {}', response.statusCode(), response.body())
}
if (response.statusCode() != 201 && response.statusCode() != 200) {
throw new RuntimeException("Failed to upload bundle: status: ${response.statusCode()} - body: ${response.body()}")
}
def deploymentId = response.body().trim()
logger.lifecycle('Uploaded release bundle successfully, deployment ID: {}', deploymentId)
return deploymentId
}
}
@Internal
String getBearerAuthorizationHeader() {
def encoded = Base64.encoder.encodeToString("${credentials.get().username}:${credentials.get().password}".getBytes(UTF_8))
return "Bearer ${encoded}"
}
@Internal
String getBasicAuthorizationHeader() {
def encoded = Base64.encoder.encodeToString("${credentials.get().username}:${credentials.get().password}".getBytes(UTF_8))
return "Basic ${encoded}"
}
static HttpClient createHttpClient() {
return HttpClient.newBuilder()
.connectTimeout(Duration.ofMinutes(1))
.build()
}
enum PublishingType {
AUTOMATIC,
USER_MANAGED,
;
}
enum DeploymentState {
PENDING,
VALIDATING,
VALIDATED,
PUBLISHING,
PUBLISHED,
FAILED,
;
}
}

View File

@ -3,6 +3,8 @@ plugins {
id 'signing'
}
def internalRepo = layout.buildDirectory.dir('repos/internal')
publishing {
publications.register('mavenJava', MavenPublication) {
pom {
@ -29,15 +31,9 @@ publishing {
}
repositories {
mavenLocal()
if (project.hasProperty("release")) {
maven {
name = 'ossrh'
def releasesRepoUrl = 'https://s01.oss.sonatype.org/service/local/staging/deploy/maven2/'
def snapshotsRepoUrl = 'https://s01.oss.sonatype.org/content/repositories/snapshots/'
url = version.endsWith('-SNAPSHOT') ? snapshotsRepoUrl : releasesRepoUrl
credentials(PasswordCredentials)
}
maven {
name = 'internal'
url = internalRepo
}
}
}
@ -45,3 +41,29 @@ publishing {
signing {
sign publishing.publications.mavenJava
}
tasks.register('createBundle', Zip) {
group = PublishingPlugin.PUBLISH_TASK_GROUP
dependsOn tasks.named('publishAllPublicationsToInternalRepository')
from(internalRepo)
archiveBaseName = "${project.group}.${project.name}"
}
tasks.register('uploadBundle', UploadBundleTask) {
group = PublishingPlugin.PUBLISH_TASK_GROUP
credentials = providers.credentials(PasswordCredentials, "portal")
bundleFile = tasks.named('createBundle', Zip).map { it.archiveFile }.get()
}
tasks.register('cleanInternalRepo', Delete) {
group = PublishingPlugin.PUBLISH_TASK_GROUP
delete internalRepo
}
tasks.named('publishAllPublicationsToInternalRepository') {
dependsOn tasks.named('cleanInternalRepo')
}
tasks.named('publish') {
dependsOn tasks.named('uploadBundle')
}