diff --git a/build-helpers/build.gradle.kts b/build-helpers/build.gradle.kts new file mode 100644 index 0000000000..abdc241eb1 --- /dev/null +++ b/build-helpers/build.gradle.kts @@ -0,0 +1,76 @@ +plugins { + kotlin("jvm") version "1.5.30" apply false + id("com.github.johnrengelman.shadow") version "7.1.0" apply false +} + +subprojects { + group = "org.jetbrains.compose.internal.build-helpers" + version = project.property("deploy.version") as String + + repositories { + mavenCentral() + } + + plugins.withType(JavaBasePlugin::class.java) { + afterEvaluate { + configureIfExists { + if (sourceSets.names.contains(SourceSet.MAIN_SOURCE_SET_NAME)) { + withJavadocJar() + withSourcesJar() + } + } + } + } + + plugins.withId("maven-publish") { + configureIfExists { + configurePublishing(project) + } + } +} + +fun PublishingExtension.configurePublishing(project: Project) { + repositories { + configureEach { + val repoName = name + project.tasks.register("publishTo${repoName}") { + group = "publishing" + dependsOn(project.tasks.named("publishAllPublicationsTo${repoName}Repository")) + } + } + maven { + name = "BuildRepo" + url = uri("${rootProject.buildDir}/repo") + } + maven { + name = "ComposeInternalRepo" + url = uri( + System.getenv("COMPOSE_INTERNAL_REPO_URL") + ?: "https://maven.pkg.jetbrains.space/public/p/compose/internal" + ) + credentials { + username = + System.getenv("COMPOSE_INTERNAL_REPO_USERNAME") + ?: System.getenv("COMPOSE_REPO_KEY") + ?: "" + password = + System.getenv("COMPOSE_INTERNAL_REPO_KEY") + ?: System.getenv("COMPOSE_REPO_KEY") + ?: "" + } + } + } + publications { + create("main") { + groupId = project.group.toString() + artifactId = project.name + version = project.version.toString() + + from(project.components["java"]) + } + } +} + +inline fun Project.configureIfExists(fn: T.() -> Unit) { + extensions.findByType(T::class.java)?.fn() +} diff --git a/build-helpers/gradle.properties b/build-helpers/gradle.properties new file mode 100644 index 0000000000..2e5973b32e --- /dev/null +++ b/build-helpers/gradle.properties @@ -0,0 +1 @@ +deploy.version=0.1.0-SNAPSHOT diff --git a/build-helpers/gradle/wrapper/gradle-wrapper.jar b/build-helpers/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000..7454180f2a Binary files /dev/null and b/build-helpers/gradle/wrapper/gradle-wrapper.jar differ diff --git a/build-helpers/gradle/wrapper/gradle-wrapper.properties b/build-helpers/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000000..69a9715077 --- /dev/null +++ b/build-helpers/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.1-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/build-helpers/gradlew b/build-helpers/gradlew new file mode 100755 index 0000000000..744e882ed5 --- /dev/null +++ b/build-helpers/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# 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 +# +# https://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. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MSYS* | MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/build-helpers/gradlew.bat b/build-helpers/gradlew.bat new file mode 100644 index 0000000000..107acd32c4 --- /dev/null +++ b/build-helpers/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/build-helpers/publishing/build.gradle.kts b/build-helpers/publishing/build.gradle.kts new file mode 100644 index 0000000000..630f071209 --- /dev/null +++ b/build-helpers/publishing/build.gradle.kts @@ -0,0 +1,50 @@ +import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar +import org.gradle.kotlin.dsl.gradleKotlinDsl + +plugins { + `java` + `maven-publish` + `java-gradle-plugin` + id("org.jetbrains.kotlin.jvm") + id("com.github.johnrengelman.shadow") +} + +repositories { + maven("https://maven.pkg.jetbrains.space/public/p/space/maven") +} + +val embeddedDependencies by configurations.creating { isTransitive = false } +dependencies { + compileOnly(gradleApi()) + compileOnly(gradleKotlinDsl()) + compileOnly(kotlin("stdlib")) + + fun embedded(dep: String) { + compileOnly(dep) + embeddedDependencies(dep) + } + + val jacksonVersion = "2.12.5" + implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-xml:$jacksonVersion") + implementation("com.fasterxml.jackson.module:jackson-module-kotlin:$jacksonVersion") + implementation("io.ktor:ktor-client-okhttp:1.6.4") + implementation("org.apache.tika:tika-parsers:1.24.1") + implementation("org.jsoup:jsoup:1.14.3") + implementation("org.jetbrains:space-sdk-jvm:83821-beta") + embedded("de.undercouch:gradle-download-task:4.1.2") +} + +val shadow = tasks.named("shadowJar") { + val fromPackage = "de.undercouch" + val toPackage = "org.jetbrains.compose.internal.publishing.$fromPackage" + relocate(fromPackage, toPackage) + archiveClassifier.set("shadow") + configurations = listOf(embeddedDependencies) + exclude("META-INF/gradle-plugins/de.undercouch.download.properties") +} + +val jar = tasks.named("jar") { + dependsOn(shadow) + from(zipTree(shadow.get().archiveFile)) + this.duplicatesStrategy = DuplicatesStrategy.INCLUDE +} \ No newline at end of file diff --git a/build-helpers/publishing/src/main/kotlin/org/jetbrains/compose/internal/publishing/DownloadFromSpaceTask.kt b/build-helpers/publishing/src/main/kotlin/org/jetbrains/compose/internal/publishing/DownloadFromSpaceTask.kt new file mode 100644 index 0000000000..f06a26f760 --- /dev/null +++ b/build-helpers/publishing/src/main/kotlin/org/jetbrains/compose/internal/publishing/DownloadFromSpaceTask.kt @@ -0,0 +1,75 @@ +/* + * Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ + +package org.jetbrains.compose.internal.publishing + +import de.undercouch.gradle.tasks.download.DownloadAction +import org.gradle.api.DefaultTask +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.TaskAction +import org.jsoup.Jsoup +import java.net.URL + +@Suppress("unused") // public api +abstract class DownloadFromSpaceMavenRepoTask : DefaultTask() { + @get:Internal + abstract val modulesToDownload: ListProperty + + @get:Internal + abstract val spaceRepoUrl: Property + + @TaskAction + fun run() { + for (module in modulesToDownload.get()) { + downloadArtifactsFromComposeDev(module) + } + } + + private fun downloadArtifactsFromComposeDev(module: ModuleToUpload) { + val groupUrl = module.groupId.replace(".", "/") + + val filesListingDocument = + Jsoup.connect("${spaceRepoUrl.get()}/$groupUrl/${module.artifactId}/${module.version}/").get() + val downloadableFiles = HashMap() + for (a in filesListingDocument.select("#contents > a")) { + val href = a.attributes().get("href") + val lastPart = href.substringAfterLast("/", "") + // check if URL points to a file + if (lastPart.isNotEmpty() && lastPart.contains(".")) { + downloadableFiles[lastPart] = URL(href) + } + } + + val destinationDir = module.localDir + if (destinationDir.exists()) { + if (module.version.endsWith("-SNAPSHOT")) { + destinationDir.deleteRecursively() + } else { + // delete existing files, that are not downloadable + val existingFiles = (destinationDir.list() ?: emptyArray()).toSet() + for (existingFileName in existingFiles) { + if (existingFileName !in downloadableFiles) { + destinationDir.resolve(existingFileName).delete() + } + } + // don't re-download all files for non-snapshot version + val it = downloadableFiles.entries.iterator() + while (it.hasNext()) { + val (fileName, _) = it.next() + if (fileName in existingFiles) { + it.remove() + } + } + } + } + + DownloadAction(project, this).apply { + src(downloadableFiles.values) + dest(destinationDir) + }.execute() + } +} \ No newline at end of file diff --git a/build-helpers/publishing/src/main/kotlin/org/jetbrains/compose/internal/publishing/FindModulesInSpaceTask.kt b/build-helpers/publishing/src/main/kotlin/org/jetbrains/compose/internal/publishing/FindModulesInSpaceTask.kt new file mode 100644 index 0000000000..92f552e2db --- /dev/null +++ b/build-helpers/publishing/src/main/kotlin/org/jetbrains/compose/internal/publishing/FindModulesInSpaceTask.kt @@ -0,0 +1,68 @@ +/* + * Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ + +package org.jetbrains.compose.internal.publishing + +import org.gradle.api.DefaultTask +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.TaskAction +import org.jetbrains.compose.internal.publishing.utils.SpaceApiClient +import space.jetbrains.api.runtime.types.PackageRepositoryIdentifier +import space.jetbrains.api.runtime.types.ProjectIdentifier + +abstract class FindModulesInSpaceTask : DefaultTask() { + @get:Input + abstract val requestedGroupId: Property + + @get:Input + abstract val requestedVersion: Property + + @get:Input + abstract val spaceInstanceUrl: Property + + @get:Internal + abstract val spaceClientId: Property + + @get:Internal + abstract val spaceClientSecret: Property + + @get:Input + abstract val spaceProjectId: Property + + @get:Input + abstract val spaceRepoId: Property + + @get:OutputFile + abstract val modulesTxtFile: RegularFileProperty + + @TaskAction + fun run() { + val space = SpaceApiClient( + serverUrl = spaceInstanceUrl.get(), + clientId = spaceClientId.get(), + clientSecret = spaceClientSecret.get() + ) + + val projectId = ProjectIdentifier.Id(spaceProjectId.get()) + val repoId = PackageRepositoryIdentifier.Id(spaceRepoId.get()) + val modules = ArrayList() + val requestedGroupId = requestedGroupId.get() + val requestedVersion = requestedVersion.get() + space.forEachPackageWithVersion(projectId, repoId, requestedVersion) { pkg -> + if (pkg.groupId.startsWith(requestedGroupId)) { + modules.add("${pkg.groupId}:${pkg.artifactId}:${pkg.version}") + } + } + + modulesTxtFile.get().asFile.apply { + parentFile.mkdirs() + writeText(modules.joinToString("\n")) + } + } +} \ No newline at end of file diff --git a/build-helpers/publishing/src/main/kotlin/org/jetbrains/compose/internal/publishing/FixModulesBeforePublishingTask.kt b/build-helpers/publishing/src/main/kotlin/org/jetbrains/compose/internal/publishing/FixModulesBeforePublishingTask.kt new file mode 100644 index 0000000000..627a8e945c --- /dev/null +++ b/build-helpers/publishing/src/main/kotlin/org/jetbrains/compose/internal/publishing/FixModulesBeforePublishingTask.kt @@ -0,0 +1,111 @@ +/* + * Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ + +package org.jetbrains.compose.internal.publishing + +import org.jetbrains.compose.internal.publishing.utils.* +import org.gradle.api.* +import org.gradle.api.tasks.* +import org.gradle.api.file.* +import org.gradle.plugins.signing.SigningExtension +import org.gradle.plugins.signing.signatory.Signatory +import org.gradle.plugins.signing.type.pgp.ArmoredSignatureType +import java.io.File +import java.util.jar.JarOutputStream + +@Suppress("unused") // public api +abstract class FixModulesBeforePublishingTask : DefaultTask() { + @get:InputFiles + abstract val inputRepoDir: DirectoryProperty + + @get:OutputDirectory + abstract val outputRepoDir: DirectoryProperty + + @get:Nested + val signatory: Signatory + get() = project.extensions.getByType(SigningExtension::class.java).signatory + + private val checksums: Checksum = defaultChecksums() + + @TaskAction + fun run() { + val inputDir = inputRepoDir.get().asFile + val outputDir = outputRepoDir.get().asFile.apply { + deleteRecursively() + mkdirs() + } + + for (inputFile in inputDir.walk()) { + if (inputFile.isDirectory + || checksums.isChecksumFile(inputFile) + || inputFile.name.endsWith(".asc") + ) continue + + val outputFile = outputDir.resolve(inputFile.relativeTo(inputDir).path) + outputFile.parentFile.mkdirs() + + logger.info("Copying and processing $inputFile to $outputFile") + if (inputFile.name.endsWith(".pom", ignoreCase = true)) { + val pom = PomDocument(inputFile) + fixPomIfNeeded(pom) + pom.saveTo(outputFile) + if (pom.packaging != "pom") { + fixSourcesAndJavadocJarIfNeeded( + inputDir = inputFile.parentFile, + outputDir = outputFile.parentFile, + baseName = inputFile.nameWithoutExtension + ) + } + } else { + inputFile.copyTo(outputFile) + } + } + + for (outputFile in outputDir.walk().filter { it.isFile }) { + // todo: make parallel + val signatureFile = outputFile.generateSignature() + checksums.generateChecksumFilesFor(outputFile) + checksums.generateChecksumFilesFor(signatureFile) + } + } + + private fun fixPomIfNeeded(pom: PomDocument) { + pom.fillMissingTags( + projectUrl = "https://github.com/JetBrains/compose-jb", + projectInceptionYear = "2020", + licenseName = "The Apache Software License, Version 2.0", + licenseUrl = "https://www.apache.org/licenses/LICENSE-2.0.txt", + licenseDistribution = "repo", + scmConnection = "scm:git:https://github.com/JetBrains/compose-jb.git", + scmDeveloperConnection = "scm:git:https://github.com/JetBrains/compose-jb.git", + scmUrl = "https://github.com/JetBrains/compose-jb", + developerName = "Compose Multiplatform Team", + developerOrganization = "JetBrains", + developerOrganizationUrl = "https://www.jetbrains.com", + ) + } + + private fun fixSourcesAndJavadocJarIfNeeded(inputDir: File, outputDir: File, baseName: String) { + val srcJar = inputDir.resolve("$baseName-sources.jar") + if (!srcJar.exists()) { + logger.warn("$srcJar does not exist. Generating empty stub") + outputDir.resolve(srcJar.name).generateEmptyJar() + } + val javadocJar = inputDir.resolve("$baseName-javadoc.jar") + if (!javadocJar.exists()) { + logger.warn("$javadocJar does not exist. Generating empty stub") + outputDir.resolve(javadocJar.name).generateEmptyJar() + } + } + + private fun File.generateEmptyJar(): File = + apply { + JarOutputStream(this.outputStream().buffered()).use { } + } + + private fun File.generateSignature(): File { + return ArmoredSignatureType().sign(signatory, this) + } +} diff --git a/build-helpers/publishing/src/main/kotlin/org/jetbrains/compose/internal/publishing/MavenCentralProperties.kt b/build-helpers/publishing/src/main/kotlin/org/jetbrains/compose/internal/publishing/MavenCentralProperties.kt new file mode 100644 index 0000000000..1fd29edc4d --- /dev/null +++ b/build-helpers/publishing/src/main/kotlin/org/jetbrains/compose/internal/publishing/MavenCentralProperties.kt @@ -0,0 +1,59 @@ +/* + * Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ + +package org.jetbrains.compose.internal.publishing + +import org.gradle.api.Project +import org.gradle.api.provider.Provider + +@Suppress("unused") // public api +class MavenCentralProperties(private val myProject: Project) { + val version: Provider = + propertyProvider("maven.central.version") + + val user: Provider = + propertyProvider("maven.central.user", envVar = "MAVEN_CENTRAL_USER") + + val password: Provider = + propertyProvider("maven.central.password", envVar = "MAVEN_CENTRAL_PASSWORD") + + val autoCommitOnSuccess: Provider = + propertyProvider("maven.central.staging.close.after.upload", defaultValue = "false") + .map { it.toBoolean() } + + val autoDropOnError: Provider = + propertyProvider("maven.central.staging.from.after.error", defaultValue = "false") + .map { it.toBoolean() } + + val signArtifacts: Boolean + get() = myProject.findProperty("maven.central.sign") == "true" + + val signArtifactsKey: Provider = + propertyProvider("maven.central.sign.key", envVar = "MAVEN_CENTRAL_SIGN_KEY") + + val signArtifactsPassword: Provider = + propertyProvider("maven.central.sign.password", envVar = "MAVEN_CENTRAL_SIGN_PASSWORD") + + private fun propertyProvider( + property: String, + envVar: String? = null, + defaultValue: String? = null + ): Provider { + val providers = myProject.providers + var result = providers.gradleProperty(property) + if (envVar != null) { + result = result.orElse(providers.environmentVariable(envVar)) + } + result = if (defaultValue != null) { + result.orElse(defaultValue) + } else { + result.orElse(providers.provider { + val envVarMessage = if (envVar != null) " or '$envVar' environment variable" else "" + error("Provide value for '$property' Gradle property$envVarMessage") + }) + } + return result + } +} \ No newline at end of file diff --git a/build-helpers/publishing/src/main/kotlin/org/jetbrains/compose/internal/publishing/ModuleToUpload.kt b/build-helpers/publishing/src/main/kotlin/org/jetbrains/compose/internal/publishing/ModuleToUpload.kt new file mode 100644 index 0000000000..1275dc9db9 --- /dev/null +++ b/build-helpers/publishing/src/main/kotlin/org/jetbrains/compose/internal/publishing/ModuleToUpload.kt @@ -0,0 +1,21 @@ +/* + * Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ + +package org.jetbrains.compose.internal.publishing + +import java.io.File + +data class ModuleToUpload( + val groupId: String, + val artifactId: String, + val version: String, + val localDir: File +) { + internal fun listFiles(): Array = + localDir.listFiles() ?: emptyArray() + + internal val coordinate: String + get() = "$groupId:$artifactId:$version" +} \ No newline at end of file diff --git a/build-helpers/publishing/src/main/kotlin/org/jetbrains/compose/internal/publishing/UploadToSonatypeTask.kt b/build-helpers/publishing/src/main/kotlin/org/jetbrains/compose/internal/publishing/UploadToSonatypeTask.kt new file mode 100644 index 0000000000..d9d8091da7 --- /dev/null +++ b/build-helpers/publishing/src/main/kotlin/org/jetbrains/compose/internal/publishing/UploadToSonatypeTask.kt @@ -0,0 +1,103 @@ +/* + * Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ + +package org.jetbrains.compose.internal.publishing + +import org.gradle.api.DefaultTask +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.TaskAction +import org.jetbrains.compose.internal.publishing.utils.* + +@Suppress("unused") // public api +abstract class UploadToSonatypeTask : DefaultTask() { + // the task must always re-run anyway, so all inputs can be declared Internal + @get:Internal + abstract val sonatypeServer: Property + + @get:Internal + abstract val user: Property + + @get:Internal + abstract val password: Property + + @get:Internal + abstract val stagingProfileName: Property + + @get:Internal + abstract val autoCommitOnSuccess: Property + + @get:Internal + abstract val autoDropOnError: Property + + @get:Internal + abstract val version: Property + + @get:Internal + abstract val modulesToUpload: ListProperty + + @TaskAction + fun run() { + SonatypeRestApiClient( + sonatypeServer = sonatypeServer.get(), + user = user.get(), + password = password.get(), + logger = logger + ).use { client -> run(client) } + } + + private fun run(sonatype: SonatypeApi) { + val stagingProfiles = sonatype.stagingProfiles() + val stagingProfileName = stagingProfileName.get() + val stagingProfile = stagingProfiles.data.firstOrNull { it.name == stagingProfileName } + ?: error( + "Cannot find staging profile '$stagingProfileName' among existing staging profiles: " + + stagingProfiles.data.joinToString { "'${it.name}'" } + ) + val modules = modulesToUpload.get() + + validate(stagingProfile, modules) + + val stagingRepo = sonatype.createStagingRepo( + stagingProfile, "Staging repo for '${stagingProfile.name}' release '${version.get()}'" + ) + try { + for (module in modules) { + sonatype.upload(stagingRepo, module) + } + if (autoCommitOnSuccess.get()) { + sonatype.closeStagingRepo(stagingRepo) + } + } catch (e: Exception) { + if (autoDropOnError.get()) { + sonatype.dropStagingRepo(stagingRepo) + } + throw e + } + } + + private fun validate(stagingProfile: StagingProfile, modules: List) { + val validationIssues = arrayListOf>() + for (module in modules) { + val status = ModuleValidator(stagingProfile, module, version.get()).validate() + if (status is ModuleValidator.Status.Error) { + validationIssues.add(module to status) + } + } + if (validationIssues.isNotEmpty()) { + val message = buildString { + appendLine("Some modules violate Maven Central requirements:") + for ((module, status) in validationIssues) { + appendLine("* ${module.coordinate} (files: ${module.localDir})") + for (error in status.errors) { + appendLine(" * $error") + } + } + } + error(message) + } + } +} \ No newline at end of file diff --git a/build-helpers/publishing/src/main/kotlin/org/jetbrains/compose/internal/publishing/utils/AbstractRestApiClient.kt b/build-helpers/publishing/src/main/kotlin/org/jetbrains/compose/internal/publishing/utils/AbstractRestApiClient.kt new file mode 100644 index 0000000000..5376e8f74d --- /dev/null +++ b/build-helpers/publishing/src/main/kotlin/org/jetbrains/compose/internal/publishing/utils/AbstractRestApiClient.kt @@ -0,0 +1,77 @@ +/* + * Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ + +package org.jetbrains.compose.internal.publishing.utils + +import okhttp3.* +import okhttp3.internal.http.RealResponseBody +import okio.Buffer +import org.gradle.api.logging.Logger +import java.net.URL +import java.time.Duration +import java.util.concurrent.atomic.AtomicLong + +internal class RestApiClient( + private val serverUrl: String, + private val user: String, + private val password: String, + private val logger: Logger, +) : AutoCloseable { + private val okClient by lazy { + OkHttpClient.Builder() + .readTimeout(Duration.ofMinutes(1)) + .build() + } + + fun buildRequest(urlPath: String, configure: Request.Builder.() -> Unit): Request = + Request.Builder().apply { + addHeader("Authorization", Credentials.basic(user, password)) + url(URL("$serverUrl/$urlPath")) + configure() + }.build() + + fun execute( + request: Request, + retries: Int = 5, + delaySec: Long = 10, + processResponse: (ResponseBody) -> T + ): T { + val message = "Remote request #${globalRequestCounter.incrementAndGet()}" + val startTimeNs = System.nanoTime() + logger.info("$message: ${request.method} '${request.url}'") + val delayMs = delaySec * 1000 + + for (i in 1..retries) { + try { + return okClient.newCall(request).execute().use { response -> + val endTimeNs = System.nanoTime() + logger.info("$message: finished in ${(endTimeNs - startTimeNs)/1_000_000} ms") + + if (!response.isSuccessful) + throw RequestError(request, response) + + val responseBody = response.body ?: RealResponseBody(null, 0, Buffer()) + processResponse(responseBody) + } + } catch (e: Exception) { + if (i == retries) { + throw RuntimeException("$message: failed all $retries attempts, see nested exception for details", e) + } + logger.info("$message: retry #$i of $retries failed. Retrying in $delayMs ms\n${e.message}") + Thread.sleep(delayMs) + } + } + + error("Unreachable") + } + + override fun close() { + okClient.connectionPool.evictAll() + } + + companion object { + private val globalRequestCounter = AtomicLong() + } +} diff --git a/build-helpers/publishing/src/main/kotlin/org/jetbrains/compose/internal/publishing/utils/Checksum.kt b/build-helpers/publishing/src/main/kotlin/org/jetbrains/compose/internal/publishing/utils/Checksum.kt new file mode 100644 index 0000000000..38a3edd6de --- /dev/null +++ b/build-helpers/publishing/src/main/kotlin/org/jetbrains/compose/internal/publishing/utils/Checksum.kt @@ -0,0 +1,80 @@ +/* + * Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ + +package org.jetbrains.compose.internal.publishing.utils + +import java.io.File +import java.security.MessageDigest + +internal fun defaultChecksums(): Checksum = CompositeChecksum( + BasicChecksum("MD5", ".md5"), + BasicChecksum("SHA-1", ".sha1"), + BasicChecksum("SHA-256", ".sha256"), + BasicChecksum("SHA-512", ".sha512"), +) + +internal abstract class Checksum { + abstract fun update(input: ByteArray) + abstract fun reset() + abstract fun write(basePath: String) + abstract fun isChecksumFile(file: File): Boolean + + fun generateChecksumFilesFor(file: File) { + reset() + update(file.readBytes()) + write(basePath = file.path) + } +} + +private class CompositeChecksum(private vararg val checksums: Checksum) : Checksum() { + override fun update(input: ByteArray) { + checksums.forEach { it.update(input) } + } + + override fun reset() { + checksums.forEach { it.reset() } + } + + override fun write(basePath: String) { + checksums.forEach { it.write(basePath) } + } + + override fun isChecksumFile(file: File): Boolean = + checksums.any { it.isChecksumFile(file) } +} + +private class BasicChecksum( + private val md: MessageDigest, + private val checksumExt: String +) : Checksum() { + constructor(algorithm: String, extension: String) : this(MessageDigest.getInstance(algorithm), extension) + + override fun update(input: ByteArray) { + md.update(input) + } + + override fun reset() { + md.reset() + } + + override fun write(basePath: String) { + File(basePath + checksumExt).writeHexString(md.digest()) + } + + override fun isChecksumFile(file: File): Boolean = + file.name.endsWith(checksumExt, ignoreCase = true) + + private fun File.writeHexString(bytes: ByteArray) { + bufferedWriter().use { writer -> + for (b in bytes) { + val hex = Integer.toHexString(0xFF and b.toInt()) + if (hex.length == 1) { + writer.append('0') + } + writer.append(hex) + } + } + } +} \ No newline at end of file diff --git a/build-helpers/publishing/src/main/kotlin/org/jetbrains/compose/internal/publishing/utils/ModuleValidator.kt b/build-helpers/publishing/src/main/kotlin/org/jetbrains/compose/internal/publishing/utils/ModuleValidator.kt new file mode 100644 index 0000000000..0188674d8b --- /dev/null +++ b/build-helpers/publishing/src/main/kotlin/org/jetbrains/compose/internal/publishing/utils/ModuleValidator.kt @@ -0,0 +1,120 @@ +/* + * Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ + +package org.jetbrains.compose.internal.publishing.utils + +import com.fasterxml.jackson.annotation.JsonRootName +import org.jetbrains.compose.internal.publishing.ModuleToUpload +import java.io.File + +internal class ModuleValidator( + private val stagingProfile: StagingProfile, + private val module: ModuleToUpload, + private val version: String +) { + private val errors = arrayListOf() + private var status: Status? = null + + sealed class Status { + object OK : Status() + class Error(val errors: List) : Status() + } + + fun validate(): Status { + if (status == null) { + validateImpl() + status = if (errors.isEmpty()) Status.OK + else Status.Error(errors) + } + + return status!! + } + + private fun validateImpl() { + if (!module.groupId.startsWith(stagingProfile.name)) { + errors.add("Module's group id '${module.groupId}' does not match staging repo '${stagingProfile.name}'") + } + + if (module.version != version) { + errors.add("Unexpected version '${module.version}' (expected: '$version')") + } + + val pomFile = artifactFile(extension = "pom") + val pom = when { + pomFile.exists() -> + try { + // todo: validate POM + Xml.deserialize(pomFile.readText()) + } catch (e: Exception) { + errors.add("Cannot deserialize $pomFile: $e") + null + } + else -> null + } + + val mandatoryFiles = arrayListOf(pomFile) + if (pom != null && pom.packaging != "pom") { + mandatoryFiles.add(artifactFile(extension = pom.packaging ?: "jar")) + mandatoryFiles.add(artifactFile(extension = "jar", classifier = "sources")) + mandatoryFiles.add(artifactFile(extension = "jar", classifier = "javadoc")) + } + + val nonExistingFiles = mandatoryFiles.filter { !it.exists() } + if (nonExistingFiles.isNotEmpty()) { + errors.add("Some necessary files do not exist: [${nonExistingFiles.map { it.name }.joinToString()}]") + } + + // signatures and checksums should not be signed themselves + val skipSignatureCheckExtensions = setOf("asc", "md5", "sha1", "sha256", "sha512") + val unsignedFiles = module.listFiles() + .filter { + it.extension !in skipSignatureCheckExtensions && !it.resolveSibling(it.name + ".asc").exists() + } + if (unsignedFiles.isNotEmpty()) { + errors.add("Some files are not signed: [${unsignedFiles.map { it.name }.joinToString()}]") + } + } + + private fun artifactFile(extension: String, classifier: String? = null): File { + val fileName = buildString { + append("${module.artifactId}-${module.version}") + if (classifier != null) + append("-$classifier") + append(".$extension") + } + return module.localDir.resolve(fileName) + } +} + + +@JsonRootName("project") +private data class Pom( + var groupId: String? = null, + var artifactId: String? = null, + var packaging: String? = null, + var name: String? = null, + var description: String? = null, + var url: String? = null, + var scm: Scm? = null, + var licenses: List? = null, + var developers: List? = null, +) { + internal data class Scm( + var connection: String?, + var developerConnection: String?, + var url: String?, + ) + + internal data class License( + var name: String? = null, + var url: String? = null + ) + + internal data class Developer( + var name: String? = null, + var organization: String? = null, + var organizationUrl: String? = null + ) +} diff --git a/build-helpers/publishing/src/main/kotlin/org/jetbrains/compose/internal/publishing/utils/PomDocument.kt b/build-helpers/publishing/src/main/kotlin/org/jetbrains/compose/internal/publishing/utils/PomDocument.kt new file mode 100644 index 0000000000..007460faa5 --- /dev/null +++ b/build-helpers/publishing/src/main/kotlin/org/jetbrains/compose/internal/publishing/utils/PomDocument.kt @@ -0,0 +1,157 @@ +/* + * Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ + +package org.jetbrains.compose.internal.publishing.utils + +import org.w3c.dom.Document +import org.w3c.dom.Element +import org.w3c.dom.Node +import java.io.File +import java.io.StringWriter +import javax.xml.parsers.DocumentBuilderFactory +import javax.xml.transform.OutputKeys +import javax.xml.transform.TransformerFactory +import javax.xml.transform.dom.DOMSource +import javax.xml.transform.stream.StreamResult + +/** +projectUrl = "https://github.com/JetBrains/compose-jb", +projectInceptionYear = "2020", +licenseName = "The Apache Software License, Version 2.0", +licenseUrl = "https://www.apache.org/licenses/LICENSE-2.0.txt", +licenseDistribution = "repo", +scmConnection = "scm:git:https://github.com/JetBrains/compose-jb.git", +scmDeveloperConnection = "scm:git:https://github.com/JetBrains/compose-jb.git", +scmUrl = "https://github.com/JetBrains/compose-jb", +developerName = "Compose Multiplatform Team", +developerOrganization = "JetBrains", +developerOrganizationUrl = "https://www.jetbrains.com", + */ +internal class PomDocument(file: File) { + private val doc: Document + val groupId: String? + val artifactId: String? + val version: String? + val packaging: String? + + init { + doc = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(file) + val projectNodes = doc.project.children().asMap() + groupId = projectNodes["groupId"]?.textContent + artifactId = projectNodes["artifactId"]?.textContent + version = projectNodes["version"]?.textContent + packaging = projectNodes["packaging"]?.textContent ?: "jar" + } + + fun coordiateAsString() = "$groupId:$artifactId:$version" + + fun saveTo(outputFile: File) { + val sw = StringWriter() + val transformer = TransformerFactory.newInstance().newTransformer().apply { + setOutputProperty(OutputKeys.ENCODING, "UTF-8") + setOutputProperty(OutputKeys.INDENT, "yes") + } + transformer.transform(DOMSource(doc), StreamResult(sw)) + outputFile.bufferedWriter().use { writer -> + for (line in sw.toString().lineSequence()) { + if (line.isNotBlank()) { + writer.appendLine(line) + } + } + } + } + + fun fillMissingTags( + projectUrl: String, + projectInceptionYear: String, + licenseName: String, + licenseUrl: String, + licenseDistribution: String, + scmConnection: String, + scmDeveloperConnection: String, + scmUrl: String, + developerName: String, + developerOrganization: String, + developerOrganizationUrl: String, + ): Unit = with (doc) { + val originalNodes = project.children().asMap() + + val nameText = originalNodes["name"]?.textContent + ?: originalNodes["artifactId"]!!.textContent + .split("-") + .joinToString(" ") { it.capitalize() } + val name = newNode("name", nameText) + val description = newNode("description", (originalNodes["description"] ?: name).textContent) + val url = newNode("url", projectUrl) + val inceptionYear = newNode("inceptionYear", projectInceptionYear) + val licences = + newNode("licenses").withChildren( + newNode("license").withChildren( + newNode("name", licenseName), + newNode("url", licenseUrl), + newNode("distribution", licenseDistribution) + ) + ) + val scm = + newNode("scm").withChildren( + newNode("connection", scmConnection), + newNode("developerConnection", scmDeveloperConnection), + newNode("url", scmUrl) + ) + val developers = + newNode("developers").withChildren( + newNode("developer").withChildren( + newNode("name", developerName), + newNode("organization", developerOrganization), + newNode("organizationUrl", developerOrganizationUrl), + ) + ) + val dependencies = originalNodes["dependencies"] + val nodesToInsert = listOf( + name, description, url, inceptionYear, licences, scm, developers, dependencies + ).filterNotNull() + for (nodeToInsert in nodesToInsert) { + val originalNode = originalNodes[nodeToInsert.nodeName] + if (originalNode != null) { + project.removeChild(originalNode) + } + project.appendChild(nodeToInsert) + } + } + + private fun Document.newNode(tag: String, value: String? = null, fn: Element.() -> Unit = {}) = + createElement(tag).apply { + if (value != null) { + appendChild(createTextNode(value)) + } + fn() + } + + private fun Element.withChildren(vararg nodes: Node): Element { + nodes.forEach { appendChild(it) } + return this + } + + private fun Node.children(): List { + val result = ArrayList(childNodes.length) + for (i in 0 until childNodes.length) { + result.add(childNodes.item(i)) + } + return result + } + + private fun List.asMap(): Map = + associateBy { it.nodeName } + + private fun Node.getChildByTag(tag: String): Node = + findChildByTag(tag) ?: error("Could not find <$tag>") + + private fun Node.findChildByTag(tag: String): Node? = + children().firstOrNull { it.nodeName == tag } + + private val Document.project: Node + get() = getChildByTag("project") + +} diff --git a/build-helpers/publishing/src/main/kotlin/org/jetbrains/compose/internal/publishing/utils/RequestError.kt b/build-helpers/publishing/src/main/kotlin/org/jetbrains/compose/internal/publishing/utils/RequestError.kt new file mode 100644 index 0000000000..c5433cd58c --- /dev/null +++ b/build-helpers/publishing/src/main/kotlin/org/jetbrains/compose/internal/publishing/utils/RequestError.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ + +package org.jetbrains.compose.internal.publishing.utils + +import okhttp3.Request +import okhttp3.Response + +internal class RequestError( + val request: Request, + val response: Response, + responseBody: String +) : RuntimeException("${request.url}: returned ${response.code}\n${responseBody.trim()}") + +internal fun RequestError(request: Request, response: Response): RequestError { + var responseBodyException: Throwable? = null + val responseBody = try { + response.body?.string() ?: "" + } catch (t: Throwable) { + responseBodyException = t + "" + } + return RequestError(request, response, responseBody).apply { + if (responseBodyException != null) addSuppressed(responseBodyException) + } +} \ No newline at end of file diff --git a/build-helpers/publishing/src/main/kotlin/org/jetbrains/compose/internal/publishing/utils/SonatypeApi.kt b/build-helpers/publishing/src/main/kotlin/org/jetbrains/compose/internal/publishing/utils/SonatypeApi.kt new file mode 100644 index 0000000000..6979b21ed3 --- /dev/null +++ b/build-helpers/publishing/src/main/kotlin/org/jetbrains/compose/internal/publishing/utils/SonatypeApi.kt @@ -0,0 +1,51 @@ +/* + * Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ + +package org.jetbrains.compose.internal.publishing.utils + +import com.fasterxml.jackson.annotation.JsonRootName +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlElementWrapper +import org.jetbrains.compose.internal.publishing.ModuleToUpload + +interface SonatypeApi { + fun upload(repo: StagingRepo, module: ModuleToUpload) + fun stagingProfiles(): StagingProfiles + fun createStagingRepo(profile: StagingProfile, description: String): StagingRepo + fun dropStagingRepo(repo: StagingRepo) + fun closeStagingRepo(repo: StagingRepo) +} + +@JsonRootName("stagingProfile") +data class StagingProfile( + var id: String = "", + var name: String = "", +) + +@JsonRootName("stagingProfiles") +class StagingProfiles( + @JacksonXmlElementWrapper + var data: List +) + +data class StagingRepo( + val id: String, + val description: String, + val profile: StagingProfile +) { + constructor( + response: PromoteResponse, + profile: StagingProfile + ) : this( + id = response.data.stagedRepositoryId!!, + description = response.data.description, + profile = profile + ) + + @JsonRootName("promoteRequest") + data class PromoteRequest(var data: PromoteData) + @JsonRootName("promoteResponse") + data class PromoteResponse(var data: PromoteData) + data class PromoteData(var stagedRepositoryId: String? = null, var description: String) +} diff --git a/build-helpers/publishing/src/main/kotlin/org/jetbrains/compose/internal/publishing/utils/SonatypeRestApiClient.kt b/build-helpers/publishing/src/main/kotlin/org/jetbrains/compose/internal/publishing/utils/SonatypeRestApiClient.kt new file mode 100644 index 0000000000..e009a1095e --- /dev/null +++ b/build-helpers/publishing/src/main/kotlin/org/jetbrains/compose/internal/publishing/utils/SonatypeRestApiClient.kt @@ -0,0 +1,102 @@ +/* + * Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ + +package org.jetbrains.compose.internal.publishing.utils + +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.Request +import okhttp3.RequestBody.Companion.asRequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.ResponseBody +import org.apache.tika.Tika +import org.gradle.api.logging.Logger +import org.jetbrains.compose.internal.publishing.ModuleToUpload +import java.io.Closeable +import java.io.File + +// https://support.sonatype.com/hc/en-us/articles/213465868-Uploading-to-a-Staging-Repository-via-REST-API +class SonatypeRestApiClient( + sonatypeServer: String, + user: String, + password: String, + private val logger: Logger, +) : SonatypeApi, Closeable { + private val client = RestApiClient(sonatypeServer, user, password, logger) + + private fun buildRequest(urlPath: String, builder: Request.Builder.() -> Unit): Request = + client.buildRequest(urlPath, builder) + + private fun Request.execute(processResponse: (ResponseBody) -> T): T = + client.execute(this, processResponse = processResponse) + + override fun close() { + client.close() + } + + override fun upload(repo: StagingRepo, module: ModuleToUpload) { + for (file in module.localDir.listFiles()!!) { + uploadFile(repo, module, file) + } + } + + private fun uploadFile(repo: StagingRepo, module: ModuleToUpload, file: File) { + val fileType = Tika().detect(file.name) + logger.info("Uploading $file (detected type='$fileType', length=${file.length()})") + val deployUrl = "service/local/staging/deployByRepositoryId/${repo.id}" + val groupUrl = module.groupId.replace(".", "/") + val coordinateUrl = "$groupUrl/${module.artifactId}/${module.version}" + val uploadUrlPath = "$deployUrl/$coordinateUrl/${file.name}" + + buildRequest(uploadUrlPath) { + header("Content-type", fileType) + put(file.asRequestBody(fileType.toMediaTypeOrNull())) + }.execute { } + } + + override fun stagingProfiles(): StagingProfiles = + buildRequest("service/local/staging/profiles") { + get() + }.execute { responseBody -> + Xml.deserialize(responseBody.string()) + } + + override fun createStagingRepo(profile: StagingProfile, description: String): StagingRepo { + logger.info("Creating sonatype staging repository for `${profile.id}` with description `$description`") + val response = + buildRequest("service/local/staging/profiles/${profile.id}/start") { + val promoteRequest = StagingRepo.PromoteRequest( + StagingRepo.PromoteData(description = description) + ) + post(Xml.serialize(promoteRequest).toRequestBody(Xml.mediaType)) + }.execute { responseBody -> + Xml.deserialize(responseBody.string()) + } + return StagingRepo(response, profile) + } + + override fun dropStagingRepo(repo: StagingRepo) { + stagingRepoAction("drop", repo) + } + + override fun closeStagingRepo(repo: StagingRepo) { + stagingRepoAction("finish", repo) + } + + private fun stagingRepoAction( + action: String, repo: StagingRepo + ) { + val logRepoDescription = "profileId='${repo.profile.id}', repoId='${repo.id}', description='${repo.description}'" + logger.info("Starting '$action': $logRepoDescription") + buildRequest("service/local/staging/${repo.profile.id}/$action") { + val promoteRequest = StagingRepo.PromoteRequest( + StagingRepo.PromoteData(stagedRepositoryId = repo.id, description = repo.description) + ) + post(Xml.serialize(promoteRequest).toRequestBody(Xml.mediaType)) + }.execute { responseBody -> + logger.info("Finished '$action': $logRepoDescription") + logger.info("Response: '${responseBody.string()}'") + } + } +} diff --git a/build-helpers/publishing/src/main/kotlin/org/jetbrains/compose/internal/publishing/utils/SpaceApiClient.kt b/build-helpers/publishing/src/main/kotlin/org/jetbrains/compose/internal/publishing/utils/SpaceApiClient.kt new file mode 100644 index 0000000000..77ae75945a --- /dev/null +++ b/build-helpers/publishing/src/main/kotlin/org/jetbrains/compose/internal/publishing/utils/SpaceApiClient.kt @@ -0,0 +1,95 @@ +/* + * Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ + +package org.jetbrains.compose.internal.publishing.utils + +import io.ktor.client.* +import io.ktor.client.engine.okhttp.* +import kotlinx.coroutines.runBlocking +import space.jetbrains.api.runtime.* +import space.jetbrains.api.runtime.resources.projects +import space.jetbrains.api.runtime.types.* + +internal class SpaceApiClient( + private val serverUrl: String, + private val clientId: String, + private val clientSecret: String, +) { + data class PackageInfo( + val groupId: String, + val artifactId: String, + val version: String + ) + + fun forEachPackageWithVersion( + projectId: ProjectIdentifier, + repoId: PackageRepositoryIdentifier, + version: String, + fn: (PackageInfo) -> Unit + ) { + withSpaceClient { + forEachPackage(projectId, repoId) { pkg -> + val details = projects.packages.repositories.packages.versions + .getPackageVersionDetails( + projectId, repoId, pkg.name, version + ) + if (details != null) { + val split = pkg.name.split("/") + if (split.size != 2) { + error("Invalid maven package name: '${pkg.name}'") + } + fn(PackageInfo(groupId = split[0], artifactId = split[1], version = version)) + } + } + } + } + + private fun withSpaceClient(fn: suspend SpaceHttpClientWithCallContext.() -> Unit) { + runBlocking { + HttpClient(OkHttp).use { client -> + val space = SpaceHttpClient(client).withServiceAccountTokenSource( + serverUrl = serverUrl, + clientId = clientId, + clientSecret = clientSecret + ) + space.fn() + } + } + } + + private fun batches(batchSize: Int = 100) = + generateSequence(0) { it + batchSize } + .map { BatchInfo(it.toString(), batchSize) } + + private suspend fun forAllInAllBatches( + getBatch: suspend (BatchInfo) -> Batch, + fn: suspend (T) -> Unit + ) { + for (batchInfo in batches()) { + val batch = getBatch(batchInfo) + + for (element in batch.data) { + fn(element) + } + + if (batch.data.isEmpty() || (batch.next.toIntOrNull() ?: 0) >= (batch.totalCount ?: 0)) return + } + } + + private suspend fun SpaceHttpClientWithCallContext.forEachPackage( + projectId: ProjectIdentifier, + repoId: PackageRepositoryIdentifier, + fn: suspend (PackageData) -> Unit + ) { + forAllInAllBatches({ batch -> + projects.packages.repositories.packages.getAllPackages( + project = projectId, + repository = repoId, + query = "", + batchInfo = batch + ) + }, fn) + } +} \ No newline at end of file diff --git a/build-helpers/publishing/src/main/kotlin/org/jetbrains/compose/internal/publishing/utils/Xml.kt b/build-helpers/publishing/src/main/kotlin/org/jetbrains/compose/internal/publishing/utils/Xml.kt new file mode 100644 index 0000000000..388224f854 --- /dev/null +++ b/build-helpers/publishing/src/main/kotlin/org/jetbrains/compose/internal/publishing/utils/Xml.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ + +package org.jetbrains.compose.internal.publishing.utils + +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.databind.MapperFeature +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.dataformat.xml.JacksonXmlModule +import com.fasterxml.jackson.dataformat.xml.XmlMapper +import com.fasterxml.jackson.module.kotlin.registerKotlinModule +import okhttp3.MediaType.Companion.toMediaType + +internal object Xml { + val mediaType = "application/xml".toMediaType() + + fun serialize(value: Any): String = + kotlinXmlMapper.writeValueAsString(value) + + inline fun deserialize(xml: String): T = + kotlinXmlMapper.readValue(xml, T::class.java) + + private val kotlinXmlMapper: ObjectMapper = + XmlMapper(JacksonXmlModule().apply { + setDefaultUseWrapper(false) + }).registerKotlinModule() + .configure(MapperFeature.ACCEPT_CASE_INSENSITIVE_PROPERTIES, true) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) +} diff --git a/build-helpers/settings.gradle.kts b/build-helpers/settings.gradle.kts new file mode 100644 index 0000000000..f5b6ed4d85 --- /dev/null +++ b/build-helpers/settings.gradle.kts @@ -0,0 +1 @@ +include(":publishing")