From d8813bc506de0bc3c4764e6b104a1c81d53ecda9 Mon Sep 17 00:00:00 2001 From: Eugene Yokota Date: Wed, 14 May 2025 00:01:20 -0400 Subject: [PATCH 01/10] Sonatype Publish Portal support **Problem** Sonatype is sunsetting HTTP Rest endpoint OSSRH in June 2025. **Solution** This implements a built-in publishing mechanism. Kudos to David Doyle who has spearheaded Sonatype Central support via sonatype-central-client etc. --- build.sbt | 8 +- .../sbt/internal/sona/DeploymentState.scala | 17 ++ .../sbt/internal/sona/PublisherStatus.scala | 45 +++++ .../sona/codec/DeploymentStateFormats.scala | 37 ++++ .../internal/sona/codec/JsonProtocol.scala | 10 + .../sona/codec/PublisherStatusFormats.scala | 33 ++++ main-actions/src/main/contraband/sona.contra | 21 +++ .../main/scala/sbt/internal/sona/Sona.scala | 177 ++++++++++++++++++ main/src/main/scala/sbt/Defaults.scala | 11 +- main/src/main/scala/sbt/Keys.scala | 3 + .../librarymanagement/Publishing.scala | 61 ++++++ project/Dependencies.scala | 1 + 12 files changed, 422 insertions(+), 2 deletions(-) create mode 100644 main-actions/src/main/contraband-scala/sbt/internal/sona/DeploymentState.scala create mode 100644 main-actions/src/main/contraband-scala/sbt/internal/sona/PublisherStatus.scala create mode 100644 main-actions/src/main/contraband-scala/sbt/internal/sona/codec/DeploymentStateFormats.scala create mode 100644 main-actions/src/main/contraband-scala/sbt/internal/sona/codec/JsonProtocol.scala create mode 100644 main-actions/src/main/contraband-scala/sbt/internal/sona/codec/PublisherStatusFormats.scala create mode 100644 main-actions/src/main/contraband/sona.contra create mode 100644 main-actions/src/main/scala/sbt/internal/sona/Sona.scala create mode 100644 main/src/main/scala/sbt/internal/librarymanagement/Publishing.scala 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" } From dc235777fea82f7db37bf22005e9fa9c31693111 Mon Sep 17 00:00:00 2001 From: Eugene Yokota Date: Thu, 15 May 2025 22:25:28 -0400 Subject: [PATCH 02/10] Implement environment variable support --- main/src/main/scala/sbt/Defaults.scala | 13 +++++++++++-- main/src/main/scala/sbt/internal/SysProp.scala | 11 +++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/main/src/main/scala/sbt/Defaults.scala b/main/src/main/scala/sbt/Defaults.scala index 99fdbd37f..90fa8800e 100644 --- a/main/src/main/scala/sbt/Defaults.scala +++ b/main/src/main/scala/sbt/Defaults.scala @@ -56,6 +56,7 @@ import sbt.internal.server.{ ServerHandler, VirtualTerminal } +import sbt.internal.sona.Sona import sbt.internal.testing.TestLogger import sbt.internal.util.Attributed.data import sbt.internal.util.Types._ @@ -227,7 +228,7 @@ object Defaults extends BuildCommon { private[sbt] lazy val globalIvyCore: Seq[Setting[_]] = Seq( internalConfigurationMap :== Configurations.internalMap _, - credentials :== SysProp.sbtCredentialsEnv.toList, + credentials := SysProp.sbtCredentialsEnv.toList, exportJars :== false, trackInternalDependencies :== TrackLevel.TrackAlways, exportToInternal :== TrackLevel.TrackAlways, @@ -3091,7 +3092,15 @@ object Classpaths { makeIvyXml := deliverTask(makeIvyXmlConfiguration).value, publish := publishOrSkip(publishConfiguration, publish / skip).value, publishLocal := publishOrSkip(publishLocalConfiguration, publishLocal / skip).value, - publishM2 := publishOrSkip(publishM2Configuration, publishM2 / skip).value + publishM2 := publishOrSkip(publishM2Configuration, publishM2 / skip).value, + credentials ++= { + val alreadyContainsCentralCredentials: Boolean = credentials.value.exists { + case d: DirectCredentials => d.host == Sona.host + case _ => false + } + if (!alreadyContainsCentralCredentials) SysProp.sonatypeCredentalsEnv.toSeq + else Nil + }, ) @nowarn("cat=deprecation") diff --git a/main/src/main/scala/sbt/internal/SysProp.scala b/main/src/main/scala/sbt/internal/SysProp.scala index 6a2dfc7aa..33a33e8f2 100644 --- a/main/src/main/scala/sbt/internal/SysProp.scala +++ b/main/src/main/scala/sbt/internal/SysProp.scala @@ -226,6 +226,17 @@ object SysProp { lazy val sbtCredentialsEnv: Option[Credentials] = sys.env.get("SBT_CREDENTIALS").map(raw => new FileCredentials(new File(raw))) + def sonatypeCredentalsEnv: Option[Credentials] = + for { + username <- sys.env.get("SONATYPE_USERNAME") + password <- sys.env.get("SONATYPE_PASSWORD") + } yield Credentials( + "Sonatype Nexus Repository Manager", + sona.Sona.host, + username, + password + ) + private[sbt] def setSwovalTempDir(): Unit = { val _ = getOrUpdateSwovalTmpDir( runtimeDirectory.resolve("swoval").toString From 341cd9cf241a5009eaa7140cee425497e042b343 Mon Sep 17 00:00:00 2001 From: Eugene Yokota Date: Thu, 15 May 2025 22:33:43 -0400 Subject: [PATCH 03/10] sonaBundle shouldn't aggregate --- main/src/main/scala/sbt/Defaults.scala | 1 + 1 file changed, 1 insertion(+) diff --git a/main/src/main/scala/sbt/Defaults.scala b/main/src/main/scala/sbt/Defaults.scala index 90fa8800e..4eca2e3b9 100644 --- a/main/src/main/scala/sbt/Defaults.scala +++ b/main/src/main/scala/sbt/Defaults.scala @@ -279,6 +279,7 @@ object Defaults extends BuildCommon { ((ThisBuild / baseDirectory).value / "target" / "sona-bundle" / "bundle.zip").toPath() ) .toFile(), + sonaBundle / aggregate :== false, commands ++= Seq(Publishing.sonaRelease, Publishing.sonaUpload), ) From 7516b4088ae31881a559e7b55c1ca19d0189fe52 Mon Sep 17 00:00:00 2001 From: Eugene Yokota Date: Sat, 17 May 2025 15:10:45 -0400 Subject: [PATCH 04/10] lm 1.11.0-M1 --- project/Dependencies.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 1b5b6940d..e3643422d 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -14,7 +14,7 @@ object Dependencies { // sbt modules private val ioVersion = nightlyVersion.getOrElse("1.10.5") private val lmVersion = - sys.props.get("sbt.build.lm.version").orElse(nightlyVersion).getOrElse("1.10.4") + sys.props.get("sbt.build.lm.version").orElse(nightlyVersion).getOrElse("1.11.0-M1") val zincVersion = nightlyVersion.getOrElse("1.10.8") private val sbtIO = "org.scala-sbt" %% "io" % ioVersion From f698b7191b662f1f29b766626da90b796a01c3d8 Mon Sep 17 00:00:00 2001 From: Eugene Yokota Date: Sat, 17 May 2025 16:07:20 -0400 Subject: [PATCH 05/10] sbt 1.11.0-RC1 --- sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sbt b/sbt index d5312ce35..49502097b 100755 --- a/sbt +++ b/sbt @@ -1,7 +1,7 @@ #!/usr/bin/env bash set +e -declare builtin_sbt_version="1.10.11" +declare builtin_sbt_version="1.11.0-RC1" declare -a residual_args declare -a java_args declare -a scalac_args From ff6ea5570b9ef07a66a4bfbb7c8504dcf1dbde32 Mon Sep 17 00:00:00 2001 From: Eugene Yokota Date: Sun, 18 May 2025 12:18:18 -0400 Subject: [PATCH 06/10] fix: Fix Central Portal status checking **Problem** It's supposed to be POST but I'm usin g GET, which results in 500. --- build.sbt | 2 +- main-actions/src/main/scala/sbt/internal/sona/Sona.scala | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/build.sbt b/build.sbt index 8737a08e5..ea8b89e18 100644 --- a/build.sbt +++ b/build.sbt @@ -11,7 +11,7 @@ import scala.util.Try // ThisBuild settings take lower precedence, // but can be shared across the multi projects. ThisBuild / version := { - val v = "1.10.11-SNAPSHOT" + val v = "1.11.0-SNAPSHOT" nightlyVersion.getOrElse(v) } ThisBuild / version2_13 := "2.0.0-SNAPSHOT" diff --git a/main-actions/src/main/scala/sbt/internal/sona/Sona.scala b/main-actions/src/main/scala/sbt/internal/sona/Sona.scala index 4549090b0..f3f95ff1e 100644 --- a/main-actions/src/main/scala/sbt/internal/sona/Sona.scala +++ b/main-actions/src/main/scala/sbt/internal/sona/Sona.scala @@ -103,7 +103,7 @@ class SonaClient(reqTransform: Request => Request) extends AutoCloseable { val req = Gigahorse .url(s"${baseUrl}/publisher/status") .addQueryString("id" -> deploymentId) - .get + .post("", StandardCharsets.UTF_8) http.run(reqTransform(req), SonaClient.asPublisherStatus) } From 929b0bf525338c43050343b60eb55edd8d58965f Mon Sep 17 00:00:00 2001 From: Eugene Yokota Date: Sun, 18 May 2025 15:17:14 -0400 Subject: [PATCH 07/10] fix: Fix Sonatype publishing **Problem** 1. query string wasn't passed in, so sonaRelease wasn't working 2. deployment name should be human readable **Solution** This fixes the query string passing by hand-crafting the URL. This also generates human readable deployment name. --- .../main/scala/sbt/internal/sona/Sona.scala | 48 +++++++++++++------ main/src/main/scala/sbt/Defaults.scala | 8 +++- main/src/main/scala/sbt/Keys.scala | 1 + .../librarymanagement/Publishing.scala | 5 +- 4 files changed, 43 insertions(+), 19 deletions(-) diff --git a/main-actions/src/main/scala/sbt/internal/sona/Sona.scala b/main-actions/src/main/scala/sbt/internal/sona/Sona.scala index f3f95ff1e..2b3e1d88a 100644 --- a/main-actions/src/main/scala/sbt/internal/sona/Sona.scala +++ b/main-actions/src/main/scala/sbt/internal/sona/Sona.scala @@ -11,6 +11,7 @@ package internal package sona import gigahorse.*, support.apachehttp.Gigahorse +import java.net.URLEncoder import java.util.Base64 import java.nio.charset.StandardCharsets import java.nio.file.Path @@ -30,7 +31,7 @@ class Sona(client: SonaClient) extends AutoCloseable { log: Logger, ): Unit = { val deploymentId = client.uploadBundle(bundleZipPath, deploymentName, pt, log) - client.waitForDeploy(deploymentId, pt, log) + client.waitForDeploy(deploymentId, deploymentName, pt, 1, log) } def close(): Unit = client.close() } @@ -49,16 +50,17 @@ class SonaClient(reqTransform: Request => Request) extends AutoCloseable { log: Logger, ): String = { val res = retryF(maxAttempt = 2) { (attempt: Int) => - log.info(s"uploading bundle to Sonatype Central (attempt: $attempt)") + log.info(s"uploading bundle to the Central Portal (attempt: $attempt)") + // addQuery string doesn't work for post + val q = queryString( + "name" -> deploymentName, + "publishingType" -> (publishingType match { + case PublishingType.Automatic => "AUTOMATIC" + case PublishingType.UserManaged => "USER_MANAGED" + }) + ) val req = Gigahorse - .url(s"${baseUrl}/publisher/upload") - .addQueryString( - "name" -> deploymentName, - "publishingType" -> (publishingType match { - case PublishingType.Automatic => "AUTOMATIC" - case PublishingType.UserManaged => "USER_MANAGED" - }) - ) + .url(s"${baseUrl}/publisher/upload?$q") .post( MultipartFormBody( FormPart("bundle", bundleZipPath.toFile()) @@ -70,23 +72,39 @@ class SonaClient(reqTransform: Request => Request) extends AutoCloseable { awaitWithMessage(res, "uploading...", log) } + def queryString(kv: (String, String)*): String = + kv.map { + case (k, v) => + val encodedV = URLEncoder.encode(v, "UTF-8") + s"$k=$encodedV" + } + .mkString("&") + def waitForDeploy( deploymentId: String, + deploymentName: String, publishingType: PublishingType, + attempt: Int, log: Logger, ): Unit = { val status = deploymentStatus(deploymentId) - log.info(s"deployment $deploymentId ${status.deploymentState}") + log.info(s"deployment $deploymentName ${status.deploymentState} ${attempt}/n") + val sleepSec = + if (attempt <= 3) List(5, 5, 10, 15)(attempt) + else 30 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) + Thread.sleep(sleepSec * 1000L) + waitForDeploy(deploymentId, deploymentName, publishingType, attempt + 1, log) case DeploymentState.PUBLISHED if publishingType == PublishingType.Automatic => () case DeploymentState.VALIDATED if publishingType == PublishingType.UserManaged => () + case DeploymentState.VALIDATED => + Thread.sleep(sleepSec * 1000L) + waitForDeploy(deploymentId, deploymentName, publishingType, attempt + 1, log) case _ => - Thread.sleep(5000) - waitForDeploy(deploymentId, publishingType, log) + Thread.sleep(sleepSec * 1000L) + waitForDeploy(deploymentId, deploymentName, publishingType, attempt + 1, log) } } diff --git a/main/src/main/scala/sbt/Defaults.scala b/main/src/main/scala/sbt/Defaults.scala index 4eca2e3b9..e6a57844d 100644 --- a/main/src/main/scala/sbt/Defaults.scala +++ b/main/src/main/scala/sbt/Defaults.scala @@ -10,7 +10,7 @@ package sbt import java.io.{ File, PrintWriter } import java.nio.file.{ Path => NioPath } -import java.util.Optional +import java.util.{ Optional, UUID } import java.util.concurrent.TimeUnit import lmcoursier.CoursierDependencyResolution import lmcoursier.definitions.{ Configuration => CConfiguration } @@ -3102,6 +3102,12 @@ object Classpaths { if (!alreadyContainsCentralCredentials) SysProp.sonatypeCredentalsEnv.toSeq else Nil }, + sonaDeploymentName := { + val o = organization.value + val v = version.value + val uuid = UUID.randomUUID().toString().take(8) + s"$o:$v:$uuid" + }, ) @nowarn("cat=deprecation") diff --git a/main/src/main/scala/sbt/Keys.scala b/main/src/main/scala/sbt/Keys.scala index 003af173f..661199c84 100644 --- a/main/src/main/scala/sbt/Keys.scala +++ b/main/src/main/scala/sbt/Keys.scala @@ -570,6 +570,7 @@ object Keys { 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 sonaDeploymentName = settingKey[String]("The name used for deployment").withRank(DSetting) 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 index 343f80593..103934c63 100644 --- a/main/src/main/scala/sbt/internal/librarymanagement/Publishing.scala +++ b/main/src/main/scala/sbt/internal/librarymanagement/Publishing.scala @@ -11,7 +11,6 @@ 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 @@ -40,12 +39,12 @@ object Publishing { private def sonatypeReleaseAction(pt: PublishingType)(s0: State): State = { val extracted = Project.extract(s0) val log = extracted.get(Keys.sLog) + val dn = extracted.get(Keys.sonaDeploymentName) 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) + client.uploadBundle(bundle.toPath(), dn, pt, log) s2 } finally { client.close() From 9e7a0fa1366ce6414a5dbeff482be609cb8e6888 Mon Sep 17 00:00:00 2001 From: Eugene Yokota Date: Wed, 21 May 2025 00:26:51 -0400 Subject: [PATCH 08/10] fail on sonaRelease if the version is SNAPSHOT --- .../librarymanagement/Publishing.scala | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/main/src/main/scala/sbt/internal/librarymanagement/Publishing.scala b/main/src/main/scala/sbt/internal/librarymanagement/Publishing.scala index 103934c63..eca6e2219 100644 --- a/main/src/main/scala/sbt/internal/librarymanagement/Publishing.scala +++ b/main/src/main/scala/sbt/internal/librarymanagement/Publishing.scala @@ -40,14 +40,22 @@ object Publishing { val extracted = Project.extract(s0) val log = extracted.get(Keys.sLog) val dn = extracted.get(Keys.sonaDeploymentName) - val (s1, bundle) = extracted.runTask(Keys.sonaBundle, s0) - val (s2, creds) = extracted.runTask(Keys.credentials, s1) - val client = fromCreds(creds) - try { - client.uploadBundle(bundle.toPath(), dn, pt, log) - s2 - } finally { - client.close() + val v = extracted.get(Keys.version) + if (v.endsWith("-SNAPSHOT")) { + log.error("""SNAPSHOTs are not supported on the Central Portal; +configure ThisBuild / publishTo to publish directly to the central-snapshots. +see https://www.scala-sbt.org/1.x/docs/Using-Sonatype.html for details.""") + s0.fail + } else { + val (s1, bundle) = extracted.runTask(Keys.sonaBundle, s0) + val (s2, creds) = extracted.runTask(Keys.credentials, s1) + val client = fromCreds(creds) + try { + client.uploadBundle(bundle.toPath(), dn, pt, log) + s2 + } finally { + client.close() + } } } From eaf12caaa3fed799f7c84305408daa2dec03fa6c Mon Sep 17 00:00:00 2001 From: Eugene Yokota Date: Sat, 24 May 2025 01:53:48 -0400 Subject: [PATCH 09/10] lm 1.11.0 --- main/src/main/scala/sbt/internal/Banner.scala | 7 +++++++ project/Dependencies.scala | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/main/src/main/scala/sbt/internal/Banner.scala b/main/src/main/scala/sbt/internal/Banner.scala index 1ed7aa04b..0a3c415e6 100644 --- a/main/src/main/scala/sbt/internal/Banner.scala +++ b/main/src/main/scala/sbt/internal/Banner.scala @@ -11,6 +11,13 @@ package sbt.internal private[sbt] object Banner { def apply(version: String): Option[String] = version match { + case v if v.startsWith("1.11.0") => + Some(s""" + |Here are some highlights of sbt 1.11.0: + | - The Central Repository publishing + |See https://eed3si9n.com/sbt-1.11.0 for full release notes. + |Hide the banner for this release by running `skipBanner`. + |""".stripMargin.linesIterator.mkString("\n")) case v if v.startsWith("1.10.0") => Some(s""" |Here are some highlights of sbt 1.10.0: diff --git a/project/Dependencies.scala b/project/Dependencies.scala index e3643422d..ce443cb03 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -14,7 +14,7 @@ object Dependencies { // sbt modules private val ioVersion = nightlyVersion.getOrElse("1.10.5") private val lmVersion = - sys.props.get("sbt.build.lm.version").orElse(nightlyVersion).getOrElse("1.11.0-M1") + sys.props.get("sbt.build.lm.version").orElse(nightlyVersion).getOrElse("1.11.0") val zincVersion = nightlyVersion.getOrElse("1.10.8") private val sbtIO = "org.scala-sbt" %% "io" % ioVersion From aaaecfcef6d00c6530db18199f54fc60b19ae2ec Mon Sep 17 00:00:00 2001 From: Eugene Yokota Date: Sat, 24 May 2025 02:38:08 -0400 Subject: [PATCH 10/10] sbt 1.11.0 --- sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sbt b/sbt index 49502097b..36f85dbf5 100755 --- a/sbt +++ b/sbt @@ -1,7 +1,7 @@ #!/usr/bin/env bash set +e -declare builtin_sbt_version="1.11.0-RC1" +declare builtin_sbt_version="1.11.0" declare -a residual_args declare -a java_args declare -a scalac_args