diff --git a/build.sbt b/build.sbt index 84d5c182c..8737a08e5 100644 --- a/build.sbt +++ b/build.sbt @@ -677,6 +677,7 @@ lazy val dependencyTreeProj = (project in file("dependency-tree")) // Implementation and support code for defining actions. lazy val actionsProj = (project in file("main-actions")) + .enablePlugins(ContrabandPlugin, JsonCodecPlugin) .dependsOn( completeProj, runProj, @@ -690,8 +691,13 @@ lazy val actionsProj = (project in file("main-actions")) .settings( testedBaseSettings, name := "Actions", + Compile / scalacOptions += "-Xsource:3", libraryDependencies += sjsonNewScalaJson.value, - libraryDependencies += jline3Terminal, + libraryDependencies ++= Seq(gigahorseOkHttp, jline3Terminal), + Compile / managedSourceDirectories += + baseDirectory.value / "src" / "main" / "contraband-scala", + Compile / generateContrabands / sourceManaged := baseDirectory.value / "src" / "main" / "contraband-scala", + Compile / generateContrabands / contrabandFormatsForType := ContrabandConfig.getFormats, mimaSettings, mimaBinaryIssueFilters ++= Seq( // Removed unused private[sbt] nested class diff --git a/main-actions/src/main/contraband-scala/sbt/internal/sona/DeploymentState.scala b/main-actions/src/main/contraband-scala/sbt/internal/sona/DeploymentState.scala new file mode 100644 index 000000000..977a68384 --- /dev/null +++ b/main-actions/src/main/contraband-scala/sbt/internal/sona/DeploymentState.scala @@ -0,0 +1,17 @@ +/** + * This code is generated using [[https://www.scala-sbt.org/contraband/ sbt-contraband]]. + */ + +// DO NOT EDIT MANUALLY +package sbt.internal.sona +sealed abstract class DeploymentState extends Serializable +object DeploymentState { + + + case object PENDING extends DeploymentState + case object VALIDATING extends DeploymentState + case object VALIDATED extends DeploymentState + case object PUBLISHING extends DeploymentState + case object PUBLISHED extends DeploymentState + case object FAILED extends DeploymentState +} diff --git a/main-actions/src/main/contraband-scala/sbt/internal/sona/PublisherStatus.scala b/main-actions/src/main/contraband-scala/sbt/internal/sona/PublisherStatus.scala new file mode 100644 index 000000000..6e1a764b1 --- /dev/null +++ b/main-actions/src/main/contraband-scala/sbt/internal/sona/PublisherStatus.scala @@ -0,0 +1,45 @@ +/** + * This code is generated using [[https://www.scala-sbt.org/contraband/ sbt-contraband]]. + */ + +// DO NOT EDIT MANUALLY +package sbt.internal.sona +/** https://central.sonatype.org/publish/publish-portal-api/#uploading-a-deployment-bundle */ +final class PublisherStatus private ( + val deploymentId: String, + val deploymentName: String, + val deploymentState: sbt.internal.sona.DeploymentState, + val purls: Vector[String]) extends Serializable { + + + + override def equals(o: Any): Boolean = this.eq(o.asInstanceOf[AnyRef]) || (o match { + case x: PublisherStatus => (this.deploymentId == x.deploymentId) && (this.deploymentName == x.deploymentName) && (this.deploymentState == x.deploymentState) && (this.purls == x.purls) + case _ => false + }) + override def hashCode: Int = { + 37 * (37 * (37 * (37 * (37 * (17 + "sbt.internal.sona.PublisherStatus".##) + deploymentId.##) + deploymentName.##) + deploymentState.##) + purls.##) + } + override def toString: String = { + "PublisherStatus(" + deploymentId + ", " + deploymentName + ", " + deploymentState + ", " + purls + ")" + } + private[this] def copy(deploymentId: String = deploymentId, deploymentName: String = deploymentName, deploymentState: sbt.internal.sona.DeploymentState = deploymentState, purls: Vector[String] = purls): PublisherStatus = { + new PublisherStatus(deploymentId, deploymentName, deploymentState, purls) + } + def withDeploymentId(deploymentId: String): PublisherStatus = { + copy(deploymentId = deploymentId) + } + def withDeploymentName(deploymentName: String): PublisherStatus = { + copy(deploymentName = deploymentName) + } + def withDeploymentState(deploymentState: sbt.internal.sona.DeploymentState): PublisherStatus = { + copy(deploymentState = deploymentState) + } + def withPurls(purls: Vector[String]): PublisherStatus = { + copy(purls = purls) + } +} +object PublisherStatus { + + def apply(deploymentId: String, deploymentName: String, deploymentState: sbt.internal.sona.DeploymentState, purls: Vector[String]): PublisherStatus = new PublisherStatus(deploymentId, deploymentName, deploymentState, purls) +} diff --git a/main-actions/src/main/contraband-scala/sbt/internal/sona/codec/DeploymentStateFormats.scala b/main-actions/src/main/contraband-scala/sbt/internal/sona/codec/DeploymentStateFormats.scala new file mode 100644 index 000000000..9da34bf74 --- /dev/null +++ b/main-actions/src/main/contraband-scala/sbt/internal/sona/codec/DeploymentStateFormats.scala @@ -0,0 +1,37 @@ +/** + * This code is generated using [[https://www.scala-sbt.org/contraband/ sbt-contraband]]. + */ + +// DO NOT EDIT MANUALLY +package sbt.internal.sona.codec +import _root_.sjsonnew.{ Unbuilder, Builder, JsonFormat, deserializationError } +trait DeploymentStateFormats { self: sjsonnew.BasicJsonProtocol => +implicit lazy val DeploymentStateFormat: JsonFormat[sbt.internal.sona.DeploymentState] = new JsonFormat[sbt.internal.sona.DeploymentState] { + override def read[J](__jsOpt: Option[J], unbuilder: Unbuilder[J]): sbt.internal.sona.DeploymentState = { + __jsOpt match { + case Some(__js) => + unbuilder.readString(__js) match { + case "PENDING" => sbt.internal.sona.DeploymentState.PENDING + case "VALIDATING" => sbt.internal.sona.DeploymentState.VALIDATING + case "VALIDATED" => sbt.internal.sona.DeploymentState.VALIDATED + case "PUBLISHING" => sbt.internal.sona.DeploymentState.PUBLISHING + case "PUBLISHED" => sbt.internal.sona.DeploymentState.PUBLISHED + case "FAILED" => sbt.internal.sona.DeploymentState.FAILED + } + case None => + deserializationError("Expected JsString but found None") + } + } + override def write[J](obj: sbt.internal.sona.DeploymentState, builder: Builder[J]): Unit = { + val str = obj match { + case sbt.internal.sona.DeploymentState.PENDING => "PENDING" + case sbt.internal.sona.DeploymentState.VALIDATING => "VALIDATING" + case sbt.internal.sona.DeploymentState.VALIDATED => "VALIDATED" + case sbt.internal.sona.DeploymentState.PUBLISHING => "PUBLISHING" + case sbt.internal.sona.DeploymentState.PUBLISHED => "PUBLISHED" + case sbt.internal.sona.DeploymentState.FAILED => "FAILED" + } + builder.writeString(str) + } +} +} diff --git a/main-actions/src/main/contraband-scala/sbt/internal/sona/codec/JsonProtocol.scala b/main-actions/src/main/contraband-scala/sbt/internal/sona/codec/JsonProtocol.scala new file mode 100644 index 000000000..01f3409a1 --- /dev/null +++ b/main-actions/src/main/contraband-scala/sbt/internal/sona/codec/JsonProtocol.scala @@ -0,0 +1,10 @@ +/** + * This code is generated using [[https://www.scala-sbt.org/contraband/ sbt-contraband]]. + */ + +// DO NOT EDIT MANUALLY +package sbt.internal.sona.codec +trait JsonProtocol extends sjsonnew.BasicJsonProtocol + with sbt.internal.sona.codec.DeploymentStateFormats + with sbt.internal.sona.codec.PublisherStatusFormats +object JsonProtocol extends JsonProtocol \ No newline at end of file diff --git a/main-actions/src/main/contraband-scala/sbt/internal/sona/codec/PublisherStatusFormats.scala b/main-actions/src/main/contraband-scala/sbt/internal/sona/codec/PublisherStatusFormats.scala new file mode 100644 index 000000000..523526990 --- /dev/null +++ b/main-actions/src/main/contraband-scala/sbt/internal/sona/codec/PublisherStatusFormats.scala @@ -0,0 +1,33 @@ +/** + * This code is generated using [[https://www.scala-sbt.org/contraband/ sbt-contraband]]. + */ + +// DO NOT EDIT MANUALLY +package sbt.internal.sona.codec +import _root_.sjsonnew.{ Unbuilder, Builder, JsonFormat, deserializationError } +trait PublisherStatusFormats { self: sbt.internal.sona.codec.DeploymentStateFormats with sjsonnew.BasicJsonProtocol => +implicit lazy val PublisherStatusFormat: JsonFormat[sbt.internal.sona.PublisherStatus] = new JsonFormat[sbt.internal.sona.PublisherStatus] { + override def read[J](__jsOpt: Option[J], unbuilder: Unbuilder[J]): sbt.internal.sona.PublisherStatus = { + __jsOpt match { + case Some(__js) => + unbuilder.beginObject(__js) + val deploymentId = unbuilder.readField[String]("deploymentId") + val deploymentName = unbuilder.readField[String]("deploymentName") + val deploymentState = unbuilder.readField[sbt.internal.sona.DeploymentState]("deploymentState") + val purls = unbuilder.readField[Vector[String]]("purls") + unbuilder.endObject() + sbt.internal.sona.PublisherStatus(deploymentId, deploymentName, deploymentState, purls) + case None => + deserializationError("Expected JsObject but found None") + } + } + override def write[J](obj: sbt.internal.sona.PublisherStatus, builder: Builder[J]): Unit = { + builder.beginObject() + builder.addField("deploymentId", obj.deploymentId) + builder.addField("deploymentName", obj.deploymentName) + builder.addField("deploymentState", obj.deploymentState) + builder.addField("purls", obj.purls) + builder.endObject() + } +} +} diff --git a/main-actions/src/main/contraband/sona.contra b/main-actions/src/main/contraband/sona.contra new file mode 100644 index 000000000..1e4bdd90c --- /dev/null +++ b/main-actions/src/main/contraband/sona.contra @@ -0,0 +1,21 @@ +package sbt.internal.sona +@target(Scala) +@codecPackage("sbt.internal.sona.codec") +@fullCodec("JsonProtocol") + +enum DeploymentState { + PENDING + VALIDATING + VALIDATED + PUBLISHING + PUBLISHED + FAILED +} + +## https://central.sonatype.org/publish/publish-portal-api/#uploading-a-deployment-bundle +type PublisherStatus { + deploymentId: String! + deploymentName: String! + deploymentState: sbt.internal.sona.DeploymentState! + purls: [String] +} diff --git a/main-actions/src/main/scala/sbt/internal/sona/Sona.scala b/main-actions/src/main/scala/sbt/internal/sona/Sona.scala new file mode 100644 index 000000000..4549090b0 --- /dev/null +++ b/main-actions/src/main/scala/sbt/internal/sona/Sona.scala @@ -0,0 +1,177 @@ +/* + * sbt + * Copyright 2023, Scala center + * Copyright 2011 - 2022, Lightbend, Inc. + * Copyright 2008 - 2010, Mark Harrah + * Licensed under Apache License 2.0 (see LICENSE) + */ + +package sbt +package internal +package sona + +import gigahorse.*, support.apachehttp.Gigahorse +import java.util.Base64 +import java.nio.charset.StandardCharsets +import java.nio.file.Path +import sbt.util.Logger +import sjsonnew.JsonFormat +import sjsonnew.support.scalajson.unsafe.{ Converter, Parser } +import sjsonnew.shaded.scalajson.ast.unsafe.JValue + +import scala.annotation.nowarn +import scala.concurrent.*, duration.* + +class Sona(client: SonaClient) extends AutoCloseable { + def uploadBundle( + bundleZipPath: Path, + deploymentName: String, + pt: PublishingType, + log: Logger, + ): Unit = { + val deploymentId = client.uploadBundle(bundleZipPath, deploymentName, pt, log) + client.waitForDeploy(deploymentId, pt, log) + } + def close(): Unit = client.close() +} + +class SonaClient(reqTransform: Request => Request) extends AutoCloseable { + import SonaClient.baseUrl + + val gigahorseConfig = Gigahorse.config + .withRequestTimeout(2.minute) + .withReadTimeout(2.minute) + val http = Gigahorse.http(gigahorseConfig) + def uploadBundle( + bundleZipPath: Path, + deploymentName: String, + publishingType: PublishingType, + log: Logger, + ): String = { + val res = retryF(maxAttempt = 2) { (attempt: Int) => + log.info(s"uploading bundle to Sonatype Central (attempt: $attempt)") + val req = Gigahorse + .url(s"${baseUrl}/publisher/upload") + .addQueryString( + "name" -> deploymentName, + "publishingType" -> (publishingType match { + case PublishingType.Automatic => "AUTOMATIC" + case PublishingType.UserManaged => "USER_MANAGED" + }) + ) + .post( + MultipartFormBody( + FormPart("bundle", bundleZipPath.toFile()) + ) + ) + .withRequestTimeout(600.second) + http.run(reqTransform(req), Gigahorse.asString) + } + awaitWithMessage(res, "uploading...", log) + } + + def waitForDeploy( + deploymentId: String, + publishingType: PublishingType, + log: Logger, + ): Unit = { + val status = deploymentStatus(deploymentId) + log.info(s"deployment $deploymentId ${status.deploymentState}") + status.deploymentState match { + case DeploymentState.FAILED => sys.error(s"deployment $deploymentId failed") + case DeploymentState.PENDING | DeploymentState.PUBLISHING | DeploymentState.VALIDATING => + Thread.sleep(5000) + waitForDeploy(deploymentId, publishingType, log) + case DeploymentState.PUBLISHED if publishingType == PublishingType.Automatic => () + case DeploymentState.VALIDATED if publishingType == PublishingType.UserManaged => () + case _ => + Thread.sleep(5000) + waitForDeploy(deploymentId, publishingType, log) + } + } + + def deploymentStatus(deploymentId: String): PublisherStatus = { + val res = retryF(maxAttempt = 5) { (attempt: Int) => + deploymentStatusF(deploymentId) + } + Await.result(res, 600.seconds) + } + + /** https://central.sonatype.org/publish/publish-portal-api/#verify-status-of-the-deployment + */ + def deploymentStatusF(deploymentId: String): Future[PublisherStatus] = { + val req = Gigahorse + .url(s"${baseUrl}/publisher/status") + .addQueryString("id" -> deploymentId) + .get + http.run(reqTransform(req), SonaClient.asPublisherStatus) + } + + /** Retry future function on any error. + */ + @nowarn + def retryF[A1](maxAttempt: Int)(f: Int => Future[A1]): Future[A1] = { + import scala.concurrent.ExecutionContext.Implicits.* + def impl(retry: Int): Future[A1] = { + val res = f(retry + 1) + res.recoverWith { + case _ if retry < maxAttempt => + Thread.sleep(5000) + impl(retry + 1) + } + } + impl(0) + } + + def awaitWithMessage[A1](f: Future[A1], msg: String, log: Logger): A1 = { + import scala.concurrent.ExecutionContext.Implicits.* + def loop(attempt: Int): Unit = + if (!f.isCompleted) { + if (attempt > 0) { + log.info(msg) + } + Future { + blocking { + Thread.sleep(30.second.toMillis) + } + }.foreach(_ => loop(attempt + 1)) + } else () + loop(0) + Await.result(f, 600.seconds) + } + + def close(): Unit = http.close() +} + +object Sona { + def host: String = SonaClient.host + def oauthClient(userName: String, userToken: String): Sona = + new Sona(SonaClient.oauthClient(userName, userToken)) +} + +object SonaClient { + import sbt.internal.sona.codec.JsonProtocol.{ *, given } + val host: String = "central.sonatype.com" + val baseUrl: String = s"https://$host/api/v1" + val asJson: FullResponse => JValue = (r: FullResponse) => + Parser.parseFromByteBuffer(r.bodyAsByteBuffer).get + def as[A1: JsonFormat]: FullResponse => A1 = asJson.andThen(Converter.fromJsonUnsafe[A1]) + val asPublisherStatus: FullResponse => PublisherStatus = as[PublisherStatus] + def oauthClient(userName: String, userToken: String): SonaClient = + new SonaClient(OAuthClient(userName, userToken)) +} + +private case class OAuthClient(userName: String, userToken: String) + extends Function1[Request, Request] { + val base64Credentials = + Base64.getEncoder.encodeToString(s"${userName}:${userToken}".getBytes(StandardCharsets.UTF_8)) + def apply(request: Request): Request = + request.addHeaders("Authorization" -> s"Bearer $base64Credentials") + override def toString: String = "OAuthClient(****)" +} + +sealed trait PublishingType +object PublishingType { + case object Automatic extends PublishingType + case object UserManaged extends PublishingType +} diff --git a/main/src/main/scala/sbt/Defaults.scala b/main/src/main/scala/sbt/Defaults.scala index 9ee85e286..99fdbd37f 100644 --- a/main/src/main/scala/sbt/Defaults.scala +++ b/main/src/main/scala/sbt/Defaults.scala @@ -269,7 +269,16 @@ object Defaults extends BuildCommon { csrMavenDependencyOverride :== false, csrSameVersions := Seq( ScalaArtifacts.Artifacts.map(a => InclExclRule(scalaOrganization.value, a)).toSet - ) + ), + stagingDirectory := (ThisBuild / baseDirectory).value / "target" / "sona-staging", + localStaging := Some(Resolver.file("local-staging", stagingDirectory.value)), + sonaBundle := Publishing + .makeBundle( + stagingDirectory.value.toPath(), + ((ThisBuild / baseDirectory).value / "target" / "sona-bundle" / "bundle.zip").toPath() + ) + .toFile(), + commands ++= Seq(Publishing.sonaRelease, Publishing.sonaUpload), ) /** Core non-plugin settings for sbt builds. These *must* be on every build or the sbt engine will fail to run at all. */ diff --git a/main/src/main/scala/sbt/Keys.scala b/main/src/main/scala/sbt/Keys.scala index dd290c3db..003af173f 100644 --- a/main/src/main/scala/sbt/Keys.scala +++ b/main/src/main/scala/sbt/Keys.scala @@ -567,6 +567,9 @@ object Keys { val forceUpdatePeriod = settingKey[Option[FiniteDuration]]("Duration after which to force a full update to occur").withRank(CSetting) val versionScheme = settingKey[Option[String]]("""Version scheme used for the subproject: Supported values are Some("early-semver"), Some("pvp"), and Some("semver-spec")""").withRank(BSetting) val libraryDependencySchemes = settingKey[Seq[ModuleID]]("""Version scheme to use for specific modules set as "org" %% "name" % "": Supported values are "early-semver", "pvp", "semver-spec", "always", and "strict".""").withRank(BSetting) + val stagingDirectory = settingKey[File]("Local staging directory for Sonatype publishing").withRank(CSetting) + val sonaBundle = taskKey[File]("Local bundle for Sonatype publishing").withRank(DTask) + val localStaging = settingKey[Option[Resolver]]("Local staging resolver for Sonatype publishing").withRank(CSetting) val classifiersModule = taskKey[GetClassifiersModule]("classifiers-module").withRank(CTask) val compatibilityWarningOptions = settingKey[CompatibilityWarningOptions]("Configures warnings around Maven incompatibility.").withRank(CSetting) diff --git a/main/src/main/scala/sbt/internal/librarymanagement/Publishing.scala b/main/src/main/scala/sbt/internal/librarymanagement/Publishing.scala new file mode 100644 index 000000000..343f80593 --- /dev/null +++ b/main/src/main/scala/sbt/internal/librarymanagement/Publishing.scala @@ -0,0 +1,61 @@ +/* + * sbt + * Copyright 2023, Scala center + * Copyright 2011 - 2022, Lightbend, Inc. + * Copyright 2008 - 2010, Mark Harrah + * Licensed under Apache License 2.0 (see LICENSE) + */ + +package sbt +package internal +package librarymanagement + +import java.nio.file.Path +import java.util.UUID +import sbt.internal.util.MessageOnlyException +import sbt.io.IO +import sbt.io.Path.contentOf +import sbt.librarymanagement.ivy.Credentials +import sona.{ Sona, PublishingType } + +object Publishing { + val sonaRelease: Command = + Command.command("sonaRelease")(sonatypeReleaseAction(PublishingType.Automatic)) + + val sonaUpload: Command = + Command.command("sonaUpload")(sonatypeReleaseAction(PublishingType.UserManaged)) + + def makeBundle(stagingDir: Path, bundlePath: Path): Path = { + if (bundlePath.toFile().exists()) { + IO.delete(bundlePath.toFile()) + } + IO.zip( + sources = contentOf(stagingDir.toFile()), + outputZip = bundlePath.toFile(), + time = Some(0L), + ) + bundlePath + } + + private def sonatypeReleaseAction(pt: PublishingType)(s0: State): State = { + val extracted = Project.extract(s0) + val log = extracted.get(Keys.sLog) + val (s1, bundle) = extracted.runTask(Keys.sonaBundle, s0) + val (s2, creds) = extracted.runTask(Keys.credentials, s1) + val client = fromCreds(creds) + try { + val uuid = UUID.randomUUID().toString().take(8) + client.uploadBundle(bundle.toPath(), uuid, pt, log) + s2 + } finally { + client.close() + } + } + + private def fromCreds(creds: Seq[Credentials]): Sona = { + val cred = Credentials + .forHost(creds, Sona.host) + .getOrElse(throw new MessageOnlyException(s"no credentials are found for ${Sona.host}")) + Sona.oauthClient(cred.userName, cred.passwd) + } +} diff --git a/project/Dependencies.scala b/project/Dependencies.scala index f3ef989a2..1b5b6940d 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -132,4 +132,5 @@ object Dependencies { val hedgehog = "qa.hedgehog" %% "hedgehog-sbt" % "0.7.0" val disruptor = "com.lmax" % "disruptor" % "3.4.2" val kindProjector = ("org.typelevel" % "kind-projector" % "0.13.3").cross(CrossVersion.full) + val gigahorseOkHttp = "com.eed3si9n" %% "gigahorse-apache-http" % "0.9.3" }