aboutsummaryrefslogtreecommitdiff
path: root/plugins/sonatype-release/src/sonatype
diff options
context:
space:
mode:
Diffstat (limited to 'plugins/sonatype-release/src/sonatype')
-rw-r--r--plugins/sonatype-release/src/sonatype/HttpUtils.scala65
-rw-r--r--plugins/sonatype-release/src/sonatype/SonatypeHttpApi.scala215
-rw-r--r--plugins/sonatype-release/src/sonatype/SonatypeLib.scala148
-rw-r--r--plugins/sonatype-release/src/sonatype/models.scala31
4 files changed, 459 insertions, 0 deletions
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
+ )