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.
This commit is contained in:
Eugene Yokota 2025-05-14 00:01:20 -04:00
parent 684cb483b3
commit d8813bc506
12 changed files with 422 additions and 2 deletions

View File

@ -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

View File

@ -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
}

View File

@ -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)
}

View File

@ -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)
}
}
}

View File

@ -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

View File

@ -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()
}
}
}

View File

@ -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]
}

View File

@ -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
}

View File

@ -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. */

View File

@ -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" % "<scheme>": 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)

View File

@ -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)
}
}

View File

@ -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"
}