diff --git a/api/build.gradle b/api/build.gradle index c8896392f..ec338099a 100644 --- a/api/build.gradle +++ b/api/build.gradle @@ -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 diff --git a/buildSrc/src/main/groovy/UploadBundleTask.groovy b/buildSrc/src/main/groovy/UploadBundleTask.groovy new file mode 100644 index 000000000..0f588b854 --- /dev/null +++ b/buildSrc/src/main/groovy/UploadBundleTask.groovy @@ -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 getPublishingType(); + + @Input + abstract Property 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() + 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, + ; + } + +} diff --git a/buildSrc/src/main/groovy/halo.publish.gradle b/buildSrc/src/main/groovy/halo.publish.gradle index 41a2f7276..3efd0e5dc 100644 --- a/buildSrc/src/main/groovy/halo.publish.gradle +++ b/buildSrc/src/main/groovy/halo.publish.gradle @@ -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') +}