diff options
Diffstat (limited to 'plugins/sonatype-release/src')
5 files changed, 510 insertions, 0 deletions
diff --git a/plugins/sonatype-release/src/SonatypeRelease.scala b/plugins/sonatype-release/src/SonatypeRelease.scala new file mode 100644 index 0000000..cb32417 --- /dev/null +++ b/plugins/sonatype-release/src/SonatypeRelease.scala @@ -0,0 +1,51 @@ +package cbt + +import cbt.sonatype.SonatypeLib + +/** + * Sonatype release plugin. + * It provides ability to release your artifacts to Sonatype OSSRH + * and publish to Central repository (aka Maven Central). + * + * Release proccess is executed in two steps: + * • `sonatypePublishSigned` + * - creates staging repository to publish artifacts; + * - publishes signed artifacts(jars) to staging repository. + * • `sonatypeRelease` + * - closes staging repository; + * - promotes staging repository to Central repository; + * - drops staging repository after release. + */ +trait SonatypeRelease extends Publish { + + def profileName: String = groupId + + def sonatypeServiceURI: String = SonatypeLib.sonatypeServiceURI + + def sonatypeSnapshotsURI: String = SonatypeLib.sonatypeSnapshotsURI + + def sonatypeCredentials: String = SonatypeLib.sonatypeCredentials + + def sonatypePublishSigned: ExitCode = + sonatypeLib.sonatypePublishSigned( + sourceFiles, + `package` :+ pom, + groupId, + artifactId, + version, + isSnapshot, + scalaMajorVersion + ) + + def sonatypePublishSignedSnapshot: ExitCode = { + copy(context.copy(version = Some(version + "-SNAPSHOT"))).sonatypePublishSigned + } + + def sonatypeRelease: ExitCode = + sonatypeLib.sonatypeRelease(groupId, artifactId, version) + + private def sonatypeLib = + new SonatypeLib(sonatypeServiceURI, sonatypeSnapshotsURI, sonatypeCredentials, profileName)(lib) + + override def copy(context: Context) = super.copy(context).asInstanceOf[SonatypeRelease] +} diff --git a/plugins/sonatype-release/src/sonatype/HttpUtils.scala b/plugins/sonatype-release/src/sonatype/HttpUtils.scala new file mode 100644 index 0000000..9d23744 --- /dev/null +++ b/plugins/sonatype-release/src/sonatype/HttpUtils.scala @@ -0,0 +1,65 @@ +package cbt.sonatype + +import java.net.URL + +import cbt.Stage0Lib + +import scala.annotation.tailrec +import scala.util.{ Failure, Success, Try } + +private[sonatype] object HttpUtils { + // Make http GET. On failure request will be retried with exponential backoff. + def GET(uri: String, headers: Map[String, String]): (Int, String) = + withRetry(httpRequest("GET", uri, headers)) + + // Make http POST. On failure request will be retried with exponential backoff. + def POST(uri: String, body: Array[Byte], headers: Map[String, String]): (Int, String) = + withRetry(httpRequest("POST", uri, headers, body)) + + private def httpRequest(method: String, uri: String, headers: Map[String, String], body: Array[Byte] = Array.emptyByteArray): (Int, String) = { + val conn = Stage0Lib.openConnectionConsideringProxy(new URL(uri)) + conn.setReadTimeout(60000) // 1 minute + conn.setConnectTimeout(30000) // 30 seconds + + headers foreach { case (k,v) => + conn.setRequestProperty(k, v) + } + conn.setRequestMethod(method) + if(method == "POST" || method == "PUT") { // PATCH too? + conn.setDoOutput(true) + conn.getOutputStream.write(body) + } + + val arr = new Array[Byte](conn.getContentLength) + conn.getInputStream.read(arr) + + conn.getResponseCode -> new String(arr) + } + + // ============== General utilities + + def withRetry[T](f: => T): T = withRetry(4000, 5)(f) + + /** + * Retry execution of `f` `retriesLeft` times + * with `delay` doubled between attempts. + */ + @tailrec + def withRetry[T](delay: Int, retriesLeft: Int)(f: ⇒ T): T = { + Try(f) match { + case Success(result) ⇒ + result + case Failure(e) ⇒ + if (retriesLeft == 0) { + throw new Exception(e) + } else { + val newDelay = delay * 2 + val newRetries = retriesLeft - 1 +// log(s"Failed with exception: $e, will retry $newRetries times; waiting: $delay") + Thread.sleep(delay) + + withRetry(newDelay, newRetries)(f) + } + } + } +} diff --git a/plugins/sonatype-release/src/sonatype/SonatypeHttpApi.scala b/plugins/sonatype-release/src/sonatype/SonatypeHttpApi.scala new file mode 100644 index 0000000..e90b81d --- /dev/null +++ b/plugins/sonatype-release/src/sonatype/SonatypeHttpApi.scala @@ -0,0 +1,215 @@ +package cbt.sonatype + +import java.util.Base64 + +import scala.xml.XML + +/** + * Interface for Sonatype staging plugin HTTP API. + * All resources are described here: + * https://oss.sonatype.org/nexus-staging-plugin/default/docs/index.html + * + * Publish proccess via HTTP API described here: + * https://support.sonatype.com/hc/en-us/articles/213465868-Uploading-to-a-Staging-Repository-via-REST-API?page=1#comment_204178478 + */ +private final class SonatypeHttpApi(sonatypeURI: String, sonatypeCredentials: String, profileName: String)(log: String => Unit) { + import HttpUtils._ + + private val base64Credentials = new String(Base64.getEncoder.encode(sonatypeCredentials.getBytes)) + + // https://oss.sonatype.org/nexus-staging-plugin/default/docs/path__staging_profiles.html + def getStagingProfile: StagingProfile = { + log(s"Retrieving info for profile: $profileName") + val (_, response) = GET( + uri = s"$sonatypeURI/staging/profiles", + headers = Map("Authorization" -> s"Basic $base64Credentials") + ) + + val currentProfile = (XML.loadString(response) \\ "stagingProfile" find { profile => + (profile \ "name").headOption.exists(_.text == profileName) + }).getOrElse(throw new Exception(s"Failed to get profile with name: $profileName")) + + StagingProfile( + id = (currentProfile \ "id").head.text, + name = (currentProfile \ "name").head.text, + repositoryTargetId = (currentProfile \ "repositoryTargetId").head.text, + resourceURI = (currentProfile \ "resourceURI").head.text + ) + } + + // https://oss.sonatype.org/nexus-staging-plugin/default/docs/path__staging_profile_repositories_-profileIdKey-.html + def getStagingRepos(profile: StagingProfile): Seq[StagingRepository] = { + log(s"Retrieving staging repositories for profile: $profileName") + val (_, response) = GET( + uri = s"$sonatypeURI/staging/profile_repositories/${profile.id}", + headers = Map( + "Authorization" -> s"Basic $base64Credentials" + ) + ) + + (XML.loadString(response) \\ "stagingProfileRepository") map extractStagingRepository + } + + // https://oss.sonatype.org/nexus-staging-plugin/default/docs/path__staging_repository_-repositoryIdKey-.html + private def getStagingRepoById(repoId: StagingRepositoryId): StagingRepository = { + log(s"Retrieving staging repo with id: ${repoId.repositoryId}") + val (_, response) = GET( + uri = s"$sonatypeURI/staging/repository/${repoId.repositoryId}", + headers = Map( + "Authorization" -> s"Basic $base64Credentials" + ) + ) + + extractStagingRepository(XML.loadString(response)) + } + + // https://oss.sonatype.org/nexus-staging-plugin/default/docs/path__staging_profiles_-profileIdKey-_start.html + def createStagingRepo(profile: StagingProfile): StagingRepositoryId = { + log(s"Creating staging repositories for profile: $profileName") + val (responseCode, response) = POST( + uri = profile.resourceURI + "/start", + body = createRequestBody("Create staging repository [CBT]").getBytes, + headers = Map( + "Authorization" -> s"Basic $base64Credentials", + "Content-Type" -> "application/xml" + ) + ) + + require(responseCode == 201, s"Create staging repo response code. Expected: 201, got: $responseCode") + + val optRepositoryId = (XML.loadString(response) \ "data" \ "stagedRepositoryId").headOption.map(e => StagingRepositoryId(e.text)) + + optRepositoryId.getOrElse(throw new Exception(s"Malformed response. Failed to get id of created staging repo")) + } + + def finishRelease(repo: StagingRepository, profile: StagingProfile): Unit = { + val repoId = StagingRepositoryId(repo.repositoryId) + repo.state match { + case Open => + closeStagingRepo(profile, repoId) + promoteStagingRepo(profile, repoId) + dropStagingRepo(profile, repoId) + case Closed => + promoteStagingRepo(profile, repoId) + dropStagingRepo(profile, repoId) + case Released => + dropStagingRepo(profile, repoId) + case Unknown(status) => + throw new Exception(s"Got repo in status: ${status}, can't finish release.") + } + } + + // https://oss.sonatype.org/nexus-staging-plugin/default/docs/path__staging_profiles_-profileIdKey-_finish.html + private def closeStagingRepo(profile: StagingProfile, repoId: StagingRepositoryId): Unit = { + log(s"Closing staging repo: ${repoId.repositoryId}") + val (responseCode, _) = POST( + uri = profile.resourceURI + "/finish", + body = promoteRequestBody( + repoId.repositoryId, + "Close staging repository [CBT]", + profile.repositoryTargetId + ).getBytes, + headers = Map( + "Authorization" -> s"Basic $base64Credentials", + "Content-Type" -> "application/xml" + ) + ) + + require(responseCode == 201, s"Close staging repo response code. Expected: 201, got: $responseCode") + } + + // https://oss.sonatype.org/nexus-staging-plugin/default/docs/path__staging_profiles_-profileIdKey-_promote.html + // You can promote repository only when it is in "closed" state. + private def promoteStagingRepo(profile: StagingProfile, repoId: StagingRepositoryId): Unit = { + log(s"Promoting staging repo: ${repoId.repositoryId}") + val responseCode = withRetry { + // need to get fresh info about this repo + val repoState = try getStagingRepoById(repoId) catch { + case e: Exception => + throw new Exception(s"Repository with id ${repoId.repositoryId} not found. Maybe it was dropped already", e) + } + + if(repoState.state == Closed) { + val (code, _) = POST( + uri = profile.resourceURI + "/promote", + body = promoteRequestBody( + repoId.repositoryId, + "Promote staging repository [CBT]", + profile.repositoryTargetId + ).getBytes, + headers = Map( + "Authorization" -> s"Basic $base64Credentials", + "Content-Type" -> "application/xml" + ) + ) + code + } else { + throw new Exception(s"Can't promote, repository ${repoId.repositoryId} is not in closed state yet!") + } + } + + require(responseCode == 201, s"Promote staging repo response code. Expected: 201, got: $responseCode") + } + + // https://oss.sonatype.org/nexus-staging-plugin/default/docs/path__staging_profiles_-profileIdKey-_drop.html + // It's safe to drop repository in "released" state. + private def dropStagingRepo(profile: StagingProfile, repoId: StagingRepositoryId): Unit = { + log(s"Dropping staging repo: ${repoId.repositoryId}") + val responseCode = withRetry { + // need to get fresh info about this repo + val repoState = try getStagingRepoById(repoId) catch { + case e: Exception => + throw new Exception(s"Repository with id ${repoId.repositoryId} not found. Maybe it was dropped already", e) + } + + if (repoState.state == Released) { + val (code, _) = POST( + uri = profile.resourceURI + "/drop", + body = promoteRequestBody( + repoId.repositoryId, + "Drop staging repository [CBT]", + profile.repositoryTargetId + ).getBytes, + headers = Map( + "Authorization" -> s"Basic $base64Credentials", + "Content-Type" -> "application/xml" + ) + ) + code + } else { + throw new Exception(s"Can't drop, repository ${repoId.repositoryId} is not in released state yet!") + } + } + require(responseCode == 201, s"Drop staging repo response code. Expected: 201, got: $responseCode") + } + + private def promoteRequestBody(repoId: String, description: String, targetRepoId: String) = + s""" + |<promoteRequest> + | <data> + | <stagedRepositoryId>$repoId</stagedRepositoryId> + | <description>$description</description> + | <targetRepositoryId>$targetRepoId</targetRepositoryId> + | </data> + |</promoteRequest> + """.stripMargin + + + private def createRequestBody(description: String) = + s""" + |<promoteRequest> + | <data> + | <description>$description</description> + | </data> + |</promoteRequest> + """.stripMargin + + private def extractStagingRepository(repo: xml.Node): StagingRepository = + StagingRepository( + (repo \ "profileId").head.text, + (repo \ "profileName").head.text, + (repo \ "repositoryId").head.text, + RepositoryState.fromString((repo \ "type").head.text) + ) +} + diff --git a/plugins/sonatype-release/src/sonatype/SonatypeLib.scala b/plugins/sonatype-release/src/sonatype/SonatypeLib.scala new file mode 100644 index 0000000..9aab9f5 --- /dev/null +++ b/plugins/sonatype-release/src/sonatype/SonatypeLib.scala @@ -0,0 +1,148 @@ +package cbt.sonatype + +import java.io.File +import java.net.URL +import java.nio.file.Files._ +import java.nio.file.Paths + +import cbt.{ ExitCode, Lib } + +/** + * Sonatype release process is: + * • get your profile info to publish artifacts + * • open staging repository to publish artifacts + * • publish signed artifacts and signatures to staging repository + * • close staging repository + * • promote staging repository + * • drop staging repository + */ + +object SonatypeLib { + + val sonatypeServiceURI: String = "https://oss.sonatype.org/service/local" + + val sonatypeSnapshotsURI: String = "https://oss.sonatype.org/content/repositories/snapshots" + + /** + * login:password for Sonatype access. + * Order of credentials lookup: + * • environment variables SONATYPE_USERNAME and SONATYPE_PASSWORD + * • ~/.cbt/sonatype-credentials + */ + def sonatypeCredentials: String = { + def fromEnv = for { + username <- Option(System.getenv("SONATYPE_USERNAME")) + password <- Option(System.getenv("SONATYPE_PASSWORD")) + } yield s"$username:$password" + + def fromFile = { + for { + home <- Option(System.getProperty("user.home")) + credsPath = Paths.get(home, ".cbt", "sonatype-credentials") + } yield new String(readAllBytes(credsPath)).trim + } + + fromEnv + .orElse(fromFile) + .getOrElse(throw new Exception( + "No Sonatype credentials found! You can provide them via SONATYPE_USERNAME, SONATYPE_PASSWORD env variables, " + + "or in ~/.cbt/sonatype-credentials file as login:password" + )) + } +} + +final class SonatypeLib( + sonatypeServiceURI: String, + sonatypeSnapshotsURI: String, + sonatypeCredentials: String, + profileName: String)(lib: Lib) { + + private val sonatypeApi = new SonatypeHttpApi(sonatypeServiceURI, sonatypeCredentials, profileName)(sonatypeLogger) + + /* + * Signed publish steps: + * • create new staging repo + * • create artifacts and sign them + * • publish jars to created repo + */ + def sonatypePublishSigned( + sourceFiles: Seq[File], + artifacts: Seq[File], + groupId: String, + artifactId: String, + version: String, + isSnapshot: Boolean, + scalaMajorVersion: String + ): ExitCode = { + if(sourceFiles.nonEmpty) { + System.err.println(lib.blue("Staring publishing to Sonatype.")) + + val profile = getStagingProfile() + + val deployURI = (if (isSnapshot) { + sonatypeSnapshotsURI + } else { + val repoId = sonatypeApi.createStagingRepo(profile) + s"${sonatypeServiceURI}/staging/deployByRepositoryId/${repoId.repositoryId}" + }) + s"/${groupId.replace(".", "/")}/${artifactId}_${scalaMajorVersion}/${version}" + + lib.publishSigned( + artifacts = artifacts, + url = new URL(deployURI), + credentials = Some(sonatypeCredentials) + ) + System.err.println(lib.green("Successfully published artifacts to Sonatype.")) + ExitCode.Success + } else { + System.err.println(lib.red("Sources are empty, won't publish empty jar.")) + ExitCode.Failure + } + } + + /** + * Release is: + * • find staging repo related to current profile; + * • close this staging repo; + * • wait until this repo is released; + * • drop this repo. + */ + def sonatypeRelease( + groupId: String, + artifactId: String, + version: String + ): ExitCode = { + val profile = getStagingProfile() + + sonatypeApi.getStagingRepos(profile).toList match { + case Nil => + System.err.println(lib.red("No staging repositories found, you need to publish artifacts first.")) + ExitCode.Failure + case repo :: Nil => + sonatypeApi.finishRelease(repo, profile) + System.err.println(lib.green(s"Successfully released ${groupId}/${artifactId} v:${version}")) + ExitCode.Success + case repos => + val showRepo = { r: StagingRepository => s"${r.repositoryId} in state: ${r.state}" } + val toRelease = lib.pickOne(lib.blue(s"More than one staging repo found. Select one of them:"), repos)(showRepo) + + toRelease map { repo => + sonatypeApi.finishRelease(repo, profile) + System.err.println(lib.green(s"Successfully released ${groupId}/${artifactId} v:${version}")) + ExitCode.Success + } getOrElse { + System.err.println(lib.red("Wrong repository number, try again please.")) + ExitCode.Failure + } + } + } + + private def getStagingProfile() = + try { + sonatypeApi.getStagingProfile + } catch { + case e: Exception => throw new Exception(s"Failed to get info for profile: $profileName", e) + } + + private def sonatypeLogger: String => Unit = lib.logger.log("Sonatype", _) + +} diff --git a/plugins/sonatype-release/src/sonatype/models.scala b/plugins/sonatype-release/src/sonatype/models.scala new file mode 100644 index 0000000..4446c53 --- /dev/null +++ b/plugins/sonatype-release/src/sonatype/models.scala @@ -0,0 +1,31 @@ +package cbt.sonatype + +case class StagingProfile( + id: String, + name: String, + repositoryTargetId: String, + resourceURI: String + ) + +case class StagingRepositoryId(repositoryId: String) + +object RepositoryState { + val fromString: String => RepositoryState = { + case "open" => Open + case "closed" => Closed + case "released" => Released + case other => Unknown(other) + } +} +sealed trait RepositoryState +case object Open extends RepositoryState +case object Closed extends RepositoryState +case object Released extends RepositoryState +case class Unknown(state: String) extends RepositoryState + +case class StagingRepository( + profileId: String, + profileName: String, + repositoryId: String, + state: RepositoryState // stands as `type` in XML response + ) |