Skip to content

Commit

Permalink
feature: Support the new Sonatype Central API (#474)
Browse files Browse the repository at this point in the history
* first working draft

* fixing formatting

* adding formatting command

* making final changes

* finalizing with formatting

* adding documentation and expanding deploy success checks

* removing scalatest

* adding deployment name documentation

* adding client library

* fixing formatting, readme, and retry errors

* modifying dependency imports

* making central classes private and formatting

---------

Co-authored-by: Taro L. Saito <leo@xerial.org>
  • Loading branch information
Andrapyre and xerial committed Jun 27, 2024
1 parent 715bd63 commit 5528534
Show file tree
Hide file tree
Showing 11 changed files with 478 additions and 92 deletions.
4 changes: 4 additions & 0 deletions .scalafmt.conf
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,7 @@ runner.dialect = scala212
maxColumn = 120
style = defaultWithAlign
optIn.breaksInsideChains = true
rewrite.rules = [Imports]
rewrite.imports.sort = original
rewrite.imports.contiguousGroups = no
rewrite.imports.groups = [["sbt.\\..*"], ["sttp.\\..*"], ["wvlet.\\..*"], ["xerial.sbt.\\..*"]]
45 changes: 37 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,26 +55,47 @@ addSbtPlugin("com.jsuereth" % "sbt-pgp" % "1.0.0")

### build.sbt

To use sbt-sonatype, you need to create a bundle of your project artifacts (e.g., .jar, .javadoc, .asc files, etc.) into a local folder specified by `sonatypeBundleDirectory`. By default, the folder is `(project root)/target/sonatype-staging/(version)`. Add the following `publishTo` setting to create a local bundle of your project:
```scala
publishTo := sonatypePublishToBundle.value
```

#### Hosts other than Sonatype Central
> ⚠️ Legacy Host
>
> By default, this plugin is configured to use the legacy Sonatype repository `oss.sonatype.org`. If you created a new account on or after February 2021, add `sonatypeCredentialHost` settings:
>
> ```scala
> ```sbt
> // For all Sonatype accounts created on or after February 2021
> ThisBuild / sonatypeCredentialHost := "s01.oss.sonatype.org"
> import xerial.sbt.Sonatype.sonatype01
>
> ThisBuild / sonatypeCredentialHost := sonatype01
> ```
#### Sonatype Central Host
As of early 2024, Sonatype has switched all new account registration over to the Sonatype Central portal and legacy `sonatype.org` accounts will eventually migrate there. To configure sbt to publish to the Sonatype Central portal, simply add the following:

```sbt
import xerial.sbt.Sonatype.sonatypeCentralHost

ThisBuild / sonatypeCredentialHost := sonatypeCentralHost
```

#### Usage

To use sbt-sonatype, you need to create a bundle of your project artifacts (e.g., .jar, .javadoc, .asc files, etc.) into a local folder specified by `sonatypeBundleDirectory`. By default, the folder is `(project root)/target/sonatype-staging/(version)`. Add the following `publishTo` setting to create a local bundle of your project:
```scala
publishTo := sonatypePublishToBundle.value
```


With this setting, `publishSigned` will create a bundle of your project to the local staging folder. If the project has multiple modules, all of the artifacts will be assembled into the same folder to create a single bundle.

If `isSnapshot.value` is true (e.g., if the version name contains -SNAPSHOT), publishSigned task will upload files to the Sonatype Snapshots repository without using the local bundle folder.

If necessary, you can tweak several configurations:
```scala
val sonatypeCentralDeploymentName =
settingKey[String]("Deployment name. Default is <organization>.<artifact_name>-<version>")
// [Optional] If you need to manage the default Sonatype Central deployment name, change the setting below.
// If publishing multiple modules, ensure that this is set on the module level, rather than on the build level.
sonatypeCentralDeploymentName := s"${organization.value}.${name.value}-${version.value}"

// [Optional] The local staging folder name:
sonatypeBundleDirectory := (ThisBuild / baseDirectory).value / target.value.getName / "sonatype-staging" / (ThisBuild / version).value

Expand Down Expand Up @@ -159,9 +180,17 @@ Note: If your project version has "SNAPSHOT" suffix, your project will be publis

## Commands

### Multi-Step Commands:
Usually, we only need to run `sonatypeBundleRelease` command in sbt-sonatype:
* __sonatypeBundleRelease__
* This will run a sequence of commands `; sonatypePrepare; sonatypeBundleUpload; sonatypeRelease` in one step.
* If `sonatypeCredentialHost` is set to a host other than the Sonatype Central portal, this command will run a sequence of commands `; sonatypePrepare; sonatypeBundleUpload; sonatypeRelease` in one step.
* If `sonatypeCredentialHost` is set to the Sonatype Central portal, this command will default to the **sonatypeCentralRelease** command.
* You must run `publishSigned` before this command to create a local staging bundle.
* __sonatypeCentralRelease__
* This will zip a bundle and upload it to the Sonatype Central portal to be released automatically after validation. This command will fail if the bundle does not pass initial validation after being uploaded.
* You must run `publishSigned` before this command to create a local staging bundle.
* __sonatypeCentralUpload__
* This will zip a bundle and upload it to the Sonatype Central portal. The bundle will not be released automatically after validation. Instead, users must manually click on `publish` in the Sonatype Central portal in order to release it. This command will fail if the bundle does not pass initial validation after being uploaded.
* You must run `publishSigned` before this command to create a local staging bundle.

### Individual Step Commands
Expand Down
26 changes: 20 additions & 6 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,23 @@
* limitations under the License.
*/

addCommandAlias("format", "scalafmtAll; scalafmtSbt")

Global / onChangedBuildSource := ReloadOnSourceChanges

// Must use Scala 2.12.x for sbt plugins
val SCALA_VERSION = "2.12.19"
val versions = new {
val scala = "2.12.19" // Must use Scala 2.12.x for sbt plugins
val airframe = "24.3.0"
val sonatypeZapperClient = "1.3"
val sttp = "4.0.0-M10"
val zioJson = "0.6.2"
val sonatypeClient = "0.1.0"
}

ThisBuild / dynverSeparator := "-"

// Set scala version for passing scala-steward run on JDK20
ThisBuild / scalaVersion := SCALA_VERSION
ThisBuild / scalaVersion := versions.scala

lazy val buildSettings: Seq[Setting[_]] = Seq(
organization := "org.xerial.sbt",
Expand All @@ -41,6 +49,7 @@ lazy val buildSettings: Seq[Setting[_]] = Seq(
}
)


val AIRFRAME_VERSION = "24.6.1"

// Project modules
Expand All @@ -54,11 +63,16 @@ lazy val sbtSonatype =
testFrameworks += new TestFramework("wvlet.airspec.Framework"),
buildInfoKeys := Seq[BuildInfoKey](name, version, scalaVersion, sbtVersion),
buildInfoPackage := "org.xerial.sbt.sonatype",
scalacOptions ++= Seq("-Ywarn-unused-import", "-nowarn"),
libraryDependencies ++= Seq(
"org.sonatype.spice.zapper" % "spice-zapper" % "1.3",
"org.wvlet.airframe" %% "airframe-http" % AIRFRAME_VERSION
"org.sonatype.spice.zapper" % "spice-zapper" % versions.sonatypeZapperClient,
"org.wvlet.airframe" %% "airframe-http" % versions.airframe
// A workaround for sbt-pgp, which still depends on scala-parser-combinator 1.x
excludeAll (ExclusionRule("org.scala-lang.modules", "scala-parser-combinators_2.12")),
"org.wvlet.airframe" %% "airspec" % AIRFRAME_VERSION % Test
"org.wvlet.airframe" %% "airspec" % versions.airframe % Test,
"com.lumidion" %% "sonatype-central-client-sttp-core" % versions.sonatypeClient,
"com.lumidion" %% "sonatype-central-client-zio-json" % versions.sonatypeClient,
"com.softwaremill.sttp.client4" %% "slf4j-backend" % versions.sttp,
"com.softwaremill.sttp.client4" %% "zio-json" % versions.sttp
)
)
169 changes: 129 additions & 40 deletions src/main/scala/xerial/sbt/Sonatype.scala
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,19 @@

package xerial.sbt

import sbt.Keys._
import sbt._
import com.lumidion.sonatype.central.client.core.{DeploymentName, PublishingType}
import sbt.*
import sbt.librarymanagement.MavenRepository
import wvlet.log.{LogLevel, LogSupport}
import xerial.sbt.sonatype.SonatypeClient.StagingRepositoryProfile
import xerial.sbt.sonatype.SonatypeService._
import xerial.sbt.sonatype.{SonatypeClient, SonatypeException, SonatypeService}

import scala.concurrent.duration.Duration
import sbt.Keys.*
import scala.concurrent.{Await, ExecutionContext, Future}
import scala.concurrent.duration.Duration
import scala.util.hashing.MurmurHash3
import wvlet.log.{LogLevel, LogSupport}
import xerial.sbt.sonatype.*
import xerial.sbt.sonatype.utils.Extensions.*
import xerial.sbt.sonatype.SonatypeClient.StagingRepositoryProfile
import xerial.sbt.sonatype.SonatypeException.GENERIC_ERROR
import xerial.sbt.sonatype.SonatypeService.*

/** Plugin for automating release processes at Sonatype Nexus
*/
Expand All @@ -27,7 +29,9 @@ object Sonatype extends AutoPlugin with LogSupport {
trait SonatypeKeys {
val sonatypeRepository = settingKey[String]("Sonatype repository URL: e.g. https://oss.sonatype.org/service/local")
val sonatypeProfileName = settingKey[String]("Profile name at Sonatype: e.g. org.xerial")
val sonatypeCredentialHost = settingKey[String]("Credential host. Default is oss.sonatype.org")
val sonatypeCredentialHost = settingKey[String]("Credential host. Default is oss.sonatype.org")
val sonatypeCentralDeploymentName =
settingKey[String]("Deployment name. Default is <organization>.<artifact_name>-<version>")
val sonatypeDefaultResolver = settingKey[Resolver]("Default Sonatype Resolver")
val sonatypePublishTo = settingKey[Option[Resolver]]("Default Sonatype publishTo target")
val sonatypePublishToBundle = settingKey[Option[Resolver]]("Default Sonatype publishTo target")
Expand All @@ -52,14 +56,15 @@ object Sonatype extends AutoPlugin with LogSupport {
override def projectSettings = sonatypeSettings
override def buildSettings = sonatypeBuildSettings

import autoImport._
import complete.DefaultParsers._
import autoImport.*
import complete.DefaultParsers.*

private implicit val ec = ExecutionContext.global

val sonatypeLegacy = "oss.sonatype.org"
val sonatype01 = "s01.oss.sonatype.org"
val knownOssHosts = Seq(sonatypeLegacy, sonatype01)
val sonatypeLegacy = "oss.sonatype.org"
val sonatype01 = "s01.oss.sonatype.org"
val sonatypeCentralHost = SonatypeCentralClient.host
val knownOssHosts = Seq(sonatypeLegacy, sonatype01)

lazy val sonatypeBuildSettings = Seq[Def.Setting[_]](
sonatypeCredentialHost := sonatypeLegacy
Expand Down Expand Up @@ -97,7 +102,12 @@ object Sonatype extends AutoPlugin with LogSupport {
if (developers.value.isEmpty) derived
else developers.value
},
sonatypePublishTo := Some(sonatypeDefaultResolver.value),
sonatypeCentralDeploymentName := DeploymentName.fromArtifact(organization.value, name.value, version.value).unapply,
sonatypePublishTo := {
if (sonatypeCredentialHost.value == SonatypeCentralClient.host && version.value.endsWith("-SNAPSHOT")) {
None
} else Some(sonatypeDefaultResolver.value)
},
sonatypeBundleDirectory := {
(ThisBuild / baseDirectory).value / "target" / "sonatype-staging" / s"${(ThisBuild / version).value}"
},
Expand All @@ -106,9 +116,13 @@ object Sonatype extends AutoPlugin with LogSupport {
},
sonatypePublishToBundle := {
if (version.value.endsWith("-SNAPSHOT")) {
// Sonatype snapshot repositories have no support for bundle upload,
// so use direct publishing to the snapshot repo.
Some(sonatypeSnapshotResolver.value)
if (sonatypeCredentialHost.value == sonatypeCentralHost) {
None
} else {
// Sonatype snapshot repositories have no support for bundle upload,
// so use direct publishing to the snapshot repo.
Some(sonatypeSnapshotResolver.value)
}
} else {
Some(Resolver.file("sonatype-local-bundle", sonatypeBundleDirectory.value))
}
Expand All @@ -126,21 +140,27 @@ object Sonatype extends AutoPlugin with LogSupport {
)
},
sonatypeDefaultResolver := {
val profileM = sonatypeTargetRepositoryProfile.?.value
val repository = sonatypeRepository.value
val staged = profileM.map { stagingRepoProfile =>
s"${sonatypeCredentialHost.value.replace('.', '-')}-releases" at s"${repository}/${stagingRepoProfile.deployPath}"
}
staged.getOrElse(if (version.value.endsWith("-SNAPSHOT")) {
sonatypeSnapshotResolver.value
if (sonatypeCredentialHost.value == SonatypeCentralClient.host) {
Resolver.url(s"https://$sonatypeCredentialHost")
} else {
sonatypeStagingResolver.value
})
val profileM = sonatypeTargetRepositoryProfile.?.value
val repository = sonatypeRepository.value
val staged = profileM.map { stagingRepoProfile =>
s"${sonatypeCredentialHost.value.replace('.', '-')}-releases" at s"${repository}/${stagingRepoProfile.deployPath}"
}
staged.getOrElse(if (version.value.endsWith("-SNAPSHOT")) {
sonatypeSnapshotResolver.value
} else {
sonatypeStagingResolver.value
})
}
},
sonatypeTimeoutMillis := 60 * 60 * 1000, // 60 minutes
sonatypeSessionName := s"[sbt-sonatype] ${name.value} ${version.value}",
sonatypeLogLevel := "info",
commands ++= Seq(
sonatypeCentralRelease,
sonatypeCentralUpload,
sonatypeBundleRelease,
sonatypeBundleUpload,
sonatypePrepare,
Expand Down Expand Up @@ -171,17 +191,60 @@ object Sonatype extends AutoPlugin with LogSupport {
val (droppedRepo, createdRepo) = Await.result(merged, Duration.Inf)
createdRepo
}
private def sonatypeCentralDeployCommand(state: State, publishingType: PublishingType): State = {
val extracted = Project.extract(state)
val bundlePath = extracted.get(sonatypeBundleDirectory)
val credentialHost = extracted.get(sonatypeCredentialHost)
val isVersionSnapshot = extracted.get(version).endsWith("-SNAPSHOT")

if (credentialHost == SonatypeCentralClient.host) {
if (isVersionSnapshot) {
error(
"Version cannot be a snapshot version when deploying to sonatype central. Please ensure that the version is publishable and try again."
)
state.fail
} else {
val deploymentName = DeploymentName(extracted.get(sonatypeCentralDeploymentName))
withSonatypeCentralService(state) { service =>
service
.uploadBundle(bundlePath, deploymentName, publishingType)
.map(_ => state)
}
}
} else {
error(
s"sonatypeCredentialHost key needs to be set to $sonatypeCentralHost in order to release to sonatype central. Please adjust the key and try again."
)
state.fail
}
}

private val sonatypeCentralUpload = newCommand(
"sonatypeCentralUpload",
"Upload a bundle in sonatypeBundleDirectory to Sonatype Central that can be released after manual approval in Sonatype Central"
)(sonatypeCentralDeployCommand(_, PublishingType.USER_MANAGED))

private val sonatypeCentralRelease = newCommand(
"sonatypeCentralRelease",
"Upload a bundle in sonatypeBundleDirectory to Sonatype Central that will be released automatically to Maven Central"
)(sonatypeCentralDeployCommand(_, PublishingType.AUTOMATIC))

private val sonatypeBundleRelease =
newCommand("sonatypeBundleRelease", "Upload a bundle in sonatypeBundleDirectory and release it at Sonatype") {
state: State =>
withSonatypeService(state) { rest =>
val repo = prepare(state, rest)
val extracted = Project.extract(state)
val bundlePath = extracted.get(sonatypeBundleDirectory)
rest.uploadBundle(bundlePath, repo.deployPath)
rest.closeAndPromote(repo)
updatePublishSettings(state, repo)
val extracted = Project.extract(state)
val credentialHost = extracted.get(sonatypeCredentialHost)

if (credentialHost == SonatypeCentralClient.host) {
sonatypeCentralDeployCommand(state, PublishingType.AUTOMATIC)
} else {
withSonatypeService(state) { rest =>
val repo = prepare(state, rest)
val bundlePath = extracted.get(sonatypeBundleDirectory)
rest.uploadBundle(bundlePath, repo.deployPath)
rest.closeAndPromote(repo)
updatePublishSettings(state, repo)
}
}
}

Expand Down Expand Up @@ -397,16 +460,42 @@ object Sonatype extends AutoPlugin with LogSupport {
"invalid input. please input a repository id"
)

private val sonatypeProfileParser: complete.Parser[Option[String]] =
(Space ~> token(StringBasic, "(sonatypeProfileName)")).?.!!!(
"invalid input. please input sonatypeProfileName (e.g., org.xerial)"
)

private def getCredentials(extracted: Extracted, state: State) = {
val (nextState, credential) = extracted.runTask(credentials, state)
val (_, credential) = extracted.runTask(credentials, state)
credential
}

private def withSonatypeCentralService(
state: State
)(func: SonatypeCentralService => Either[SonatypeException, State]): State = {
val extracted = Project.extract(state)
val logLevel = LogLevel(extracted.get(sonatypeLogLevel))
wvlet.log.Logger.setDefaultLogLevel(logLevel)

val credentials = getCredentials(extracted, state)

val eitherOp = for {
client <- SonatypeCentralClient.fromCredentials(credentials)
service = new SonatypeCentralService(client)
res <-
try {
func(service)
} catch {
case e: Throwable => Left(new SonatypeException(GENERIC_ERROR, e.getMessage))
} finally {
client.close()
}
} yield res

try {
eitherOp.getOrError
} catch {
case e: SonatypeException =>
error(e.toString)
state.fail
}
}

private def withSonatypeService(state: State, profileName: Option[String] = None)(
body: SonatypeService => State
): State = {
Expand Down
Loading

0 comments on commit 5528534

Please sign in to comment.