Skip to content
Permalink

Comparing changes

Choose two branches to see what’s changed or to start a new pull request. If you need to, you can also or learn more about diff comparisons.

Open a pull request

Create a new pull request by comparing changes across two branches. If you need to, you can also . Learn more about diff comparisons here.
base repository: xerial/sbt-sonatype
Failed to load repositories. Confirm that selected base ref is valid, then try again.
Loading
base: v3.10.0
Choose a base ref
...
head repository: xerial/sbt-sonatype
Failed to load repositories. Confirm that selected head ref is valid, then try again.
Loading
compare: v3.11.0
Choose a head ref

Commits on Nov 16, 2023

  1. Verified

    This commit was signed with the committer’s verified signature.
    joyeecheung Joyee Cheung
    Copy the full SHA
    c7137a1 View commit details
  2. Verified

    This commit was signed with the committer’s verified signature.
    joyeecheung Joyee Cheung
    Copy the full SHA
    dfc07de View commit details

Commits on Dec 4, 2023

  1. Bump actions/setup-java from 3 to 4 (#457)

    Bumps [actions/setup-java](https://github.com/actions/setup-java) from 3 to 4.
    - [Release notes](https://github.com/actions/setup-java/releases)
    - [Commits](actions/setup-java@v3...v4)
    
    ---
    updated-dependencies:
    - dependency-name: actions/setup-java
      dependency-type: direct:production
      update-type: version-update:semver-major
    ...
    
    Signed-off-by: dependabot[bot] <support@github.com>
    Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
    dependabot[bot] authored Dec 4, 2023
    Copy the full SHA
    9b65dcb View commit details

Commits on Dec 14, 2023

  1. Verified

    This commit was signed with the committer’s verified signature.
    aduh95 Antoine du Hamel
    Copy the full SHA
    24dc635 View commit details

Commits on Dec 15, 2023

  1. Verified

    This commit was signed with the committer’s verified signature.
    aduh95 Antoine du Hamel
    Copy the full SHA
    da75ed2 View commit details

Commits on Dec 18, 2023

  1. Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature.
    Copy the full SHA
    210b275 View commit details

Commits on Jan 9, 2024

  1. Verified

    This commit was signed with the committer’s verified signature.
    aduh95 Antoine du Hamel
    Copy the full SHA
    18bbe7c View commit details

Commits on Jan 18, 2024

  1. Verified

    This commit was signed with the committer’s verified signature.
    aduh95 Antoine du Hamel
    Copy the full SHA
    a8270ed View commit details
  2. feature: Give helpful advice when profile missing on host (#461)

    * Give helpful advice when profile missing on host
    
    This updates the error message given when a profile is not found on a host (note Sonatype currently has 2
    OSS hosts - `oss.sonatype.org` & `s01.oss.sonatype.org` and any profile will only exist on _one_ of the
    hosts).
    
    There can be two possible causes when a profile is not found on a host:
    
    1. The profile name is misspelt/incorrect (eg 'com.gnu' or 'john.smith' instead of 'com.gu')
    2. The profile is actually on the _other_ Sonatype OSS host
    
    The original error message really only indicated the possibility of the _1st_ problem, but the 2nd issue
    is increasingly likely - since February 2021 new profiles have _only_ been registered on the new host
    `s01.oss.sonatype.org` (https://central.sonatype.org/news/20210223_new-users-on-s01/), and `sbt-sonatype`
    defaults to using the other host, `oss.sonatype.org`.
    
    The new error message aims to make the user aware of the 2 possible causes of the 'missing profile'
    problem, and offer advice on how the 2nd problem could be corrected.
    
    Before:
    
    > Profile com.gu is not found. Check your sonatypeProfileName setting in build.sbt
    
    After:
    
    > Profile com.gu is not found on oss.sonatype.org. In your sbt settings, check your sonatypeProfileName and sonatypeCredentialHost (try s01.oss.sonatype.org?)"
    
    * Apply suggested fixes
    
    ---------
    
    Co-authored-by: Taro L. Saito <leo@xerial.org>
    rtyley and xerial authored Jan 18, 2024

    Verified

    This commit was signed with the committer’s verified signature.
    aduh95 Antoine du Hamel
    Copy the full SHA
    17e3464 View commit details

Commits on Jan 31, 2024

  1. Copy the full SHA
    81fa203 View commit details

Commits on Feb 5, 2024

  1. Bump release-drafter/release-drafter from 5 to 6 (#467)

    Bumps [release-drafter/release-drafter](https://github.com/release-drafter/release-drafter) from 5 to 6.
    - [Release notes](https://github.com/release-drafter/release-drafter/releases)
    - [Commits](release-drafter/release-drafter@v5...v6)
    
    ---
    updated-dependencies:
    - dependency-name: release-drafter/release-drafter
      dependency-type: direct:production
      update-type: version-update:semver-major
    ...
    
    Signed-off-by: dependabot[bot] <support@github.com>
    Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
    dependabot[bot] authored Feb 5, 2024
    Copy the full SHA
    3800dca View commit details

Commits on Feb 19, 2024

  1. Update scalafmt-core to 3.8.0 (#468)

    xerial-bot authored Feb 19, 2024
    Copy the full SHA
    9f57b1b View commit details

Commits on Feb 20, 2024

  1. Update airframe-http, airspec to 24.2.0 (#469)

    xerial-bot authored Feb 20, 2024
    Copy the full SHA
    8a23662 View commit details

Commits on Feb 23, 2024

  1. Update sbt, scripted-plugin to 1.9.9 (#470)

    xerial-bot authored Feb 23, 2024
    Copy the full SHA
    cce68c3 View commit details
  2. Update airframe-http, airspec to 24.2.1 (#471)

    xerial-bot authored Feb 23, 2024
    Copy the full SHA
    6b74443 View commit details

Commits on Feb 25, 2024

  1. Update airframe-http, airspec to 24.2.2 (#472)

    xerial-bot authored Feb 25, 2024
    Copy the full SHA
    26f7f84 View commit details

Commits on Feb 27, 2024

  1. Update scala-library to 2.12.19 (#473)

    xerial-bot authored Feb 27, 2024
    Copy the full SHA
    7db77a0 View commit details

Commits on Feb 28, 2024

  1. Update airframe-http, airspec to 24.2.3 (#475)

    xerial-bot authored Feb 28, 2024
    Copy the full SHA
    e299ed5 View commit details

Commits on Mar 1, 2024

  1. Update airframe-http, airspec to 24.3.0 (#476)

    xerial-bot authored Mar 1, 2024
    Copy the full SHA
    4e18ee0 View commit details

Commits on Mar 20, 2024

  1. Update sbt-buildinfo to 0.12.0 (#477)

    xerial-bot authored Mar 20, 2024
    Copy the full SHA
    361fdcd View commit details

Commits on Mar 29, 2024

  1. Update scalafmt-core to 3.8.1 (#478)

    xerial-bot authored Mar 29, 2024
    Copy the full SHA
    513a268 View commit details

Commits on Apr 9, 2024

  1. Update airframe-http, airspec to 24.4.0 (#479)

    xerial-bot authored Apr 9, 2024
    Copy the full SHA
    6d4b431 View commit details

Commits on Apr 21, 2024

  1. Update airframe-http, airspec to 24.4.1 (#480)

    xerial-bot authored Apr 21, 2024
    Copy the full SHA
    ae20034 View commit details

Commits on Apr 23, 2024

  1. Update airframe-http, airspec to 24.4.2 (#481)

    xerial-bot authored Apr 23, 2024
    Copy the full SHA
    7224a1c View commit details

Commits on Apr 24, 2024

  1. Update airframe-http, airspec to 24.4.3 (#482)

    xerial-bot authored Apr 24, 2024
    Copy the full SHA
    972727d View commit details

Commits on May 6, 2024

  1. Update sbt, scripted-plugin to 1.10.0 (#484)

    xerial-bot authored May 6, 2024
    Copy the full SHA
    ed77739 View commit details
  2. Update airframe-http, airspec to 24.5.0 (#485)

    xerial-bot authored May 6, 2024
    Copy the full SHA
    0d3b840 View commit details

Commits on May 31, 2024

  1. Update airframe-http, airspec to 24.5.1 (#486)

    xerial-bot authored May 31, 2024
    Copy the full SHA
    d555b4b View commit details
  2. Update airframe-http, airspec to 24.5.2 (#487)

    xerial-bot authored May 31, 2024
    Copy the full SHA
    fbb6785 View commit details

Commits on Jun 7, 2024

  1. Update airframe-http, airspec to 24.6.0 (#488)

    xerial-bot authored Jun 7, 2024
    Copy the full SHA
    041b33d View commit details

Commits on Jun 14, 2024

  1. Update scalafmt-core to 3.8.2 (#489)

    * Update scalafmt-core to 3.8.2
    
    * Reformat with scalafmt 3.8.2
    
    Executed command: scalafmt --non-interactive
    
    * Add 'Reformat with scalafmt 3.8.2' to .git-blame-ignore-revs
    xerial-bot authored Jun 14, 2024
    Copy the full SHA
    d634a4d View commit details

Commits on Jun 19, 2024

  1. Update airframe-http, airspec to 24.6.1 (#490)

    xerial-bot authored Jun 19, 2024
    Copy the full SHA
    715bd63 View commit details

Commits on Jun 27, 2024

  1. feature: Support the new Sonatype Central API (#474)

    * 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>
    Andrapyre and xerial authored Jun 27, 2024
    Copy the full SHA
    5528534 View commit details
3 changes: 3 additions & 0 deletions .git-blame-ignore-revs
Original file line number Diff line number Diff line change
@@ -3,3 +3,6 @@ c3efd683f835329e057719c1f2ededac55589933

# Scala Steward: Reformat with scalafmt 3.7.15
99aee8ec7e98cd260d674cca8f05505133e43d73

# Scala Steward: Reformat with scalafmt 3.8.2
d2b649ac01679f6b4a9482411665600e31209a43
2 changes: 1 addition & 1 deletion .github/workflows/release-drafter.yml
Original file line number Diff line number Diff line change
@@ -18,6 +18,6 @@ jobs:
pull-requests: write
runs-on: ubuntu-latest
steps:
- uses: release-drafter/release-drafter@v5
- uses: release-drafter/release-drafter@v6
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
4 changes: 2 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v3
- uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: 11
@@ -30,7 +30,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v3
- uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: 17
6 changes: 5 additions & 1 deletion .scalafmt.conf
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
version = 3.7.15
version = 3.8.2
project.layout = StandardConvention
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
@@ -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

@@ -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
28 changes: 21 additions & 7 deletions build.sbt
Original file line number Diff line number Diff line change
@@ -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.18"
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",
@@ -41,7 +49,8 @@ lazy val buildSettings: Seq[Setting[_]] = Seq(
}
)

val AIRFRAME_VERSION = "23.11.3"

val AIRFRAME_VERSION = "24.6.1"

// Project modules
lazy val sbtSonatype =
@@ -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
)
)
2 changes: 1 addition & 1 deletion project/build.properties
Original file line number Diff line number Diff line change
@@ -1 +1 @@
sbt.version=1.9.7
sbt.version=1.10.0
4 changes: 2 additions & 2 deletions project/plugins.sbt
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
val SONATYPE_VERSION = sys.env.getOrElse("SONATYPE_VERSION", "3.9.21")
val SONATYPE_VERSION = sys.env.getOrElse("SONATYPE_VERSION", "3.10.0")
addSbtPlugin("org.xerial.sbt" % "sbt-sonatype" % SONATYPE_VERSION)
addSbtPlugin("com.github.sbt" % "sbt-pgp" % "2.2.1")
addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.2")
addSbtPlugin("com.github.sbt" % "sbt-dynver" % "5.0.1")
addSbtPlugin("com.eed3si9n" % "sbt-buildinfo" % "0.11.0")
addSbtPlugin("com.eed3si9n" % "sbt-buildinfo" % "0.12.0")

libraryDependencies += "org.scala-sbt" %% "scripted-plugin" % sbtVersion.value

168 changes: 129 additions & 39 deletions src/main/scala/xerial/sbt/Sonatype.scala
Original file line number Diff line number Diff line change
@@ -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
*/
@@ -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")
@@ -52,13 +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 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
@@ -96,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}"
},
@@ -105,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))
}
@@ -125,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,
@@ -170,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)
}
}
}

@@ -396,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 = {
131 changes: 131 additions & 0 deletions src/main/scala/xerial/sbt/sonatype/SonatypeCentralClient.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
package xerial.sbt.sonatype

import com.lumidion.sonatype.central.client.core.{
CheckStatusResponse,
DeploymentId,
DeploymentName,
DeploymentState,
PublishingType
}
import com.lumidion.sonatype.central.client.core.DeploymentState.PUBLISHED
import com.lumidion.sonatype.central.client.sttp.core.SyncSonatypeClient
import com.lumidion.sonatype.central.client.zio.json.decoders.*
import java.io.File
import sbt.librarymanagement.ivy.Credentials
import scala.math.pow
import scala.util.Try
import sttp.client4.{HttpError, ResponseException}
import sttp.client4.httpurlconnection.HttpURLConnectionBackend
import sttp.client4.logging.slf4j.Slf4jLoggingBackend
import sttp.client4.logging.LoggingOptions
import sttp.client4.ziojson.asJson
import sttp.model.StatusCode
import wvlet.log.LogSupport
import xerial.sbt.sonatype.utils.Extensions.*
import xerial.sbt.sonatype.SonatypeException.{BUNDLE_UPLOAD_FAILURE, STATUS_CHECK_FAILURE, USER_ERROR}

private[sonatype] class SonatypeCentralClient(
client: SyncSonatypeClient
) extends AutoCloseable
with LogSupport {

private def retryRequest[A, E](
request: => Either[ResponseException[String, E], A],
errorContext: String,
errorCode: ErrorCode,
retriesLeft: Int,
retriesAttempted: Int = 0
): Either[SonatypeException, A] = {
for {
response <- Try(request).toEither.leftMap { err =>
SonatypeException(errorCode, s"$errorContext. ${err.getMessage}")
}
finalResponse <- response match {
case Left(HttpError(message, code))
if (code == StatusCode.Forbidden) || (code == StatusCode.Unauthorized) || (code == StatusCode.BadRequest) =>
Left(
new SonatypeException(USER_ERROR, s"$errorContext. Status code: ${code.code}. Message Received: $message")
)
case Left(ex) =>
if (retriesLeft > 0) {
val exponent = pow(5, retriesAttempted).toInt
val maximum = 30000
val initialMillisecondsToSleep = 1500 + exponent
val finalMillisecondsToSleep = if (maximum < initialMillisecondsToSleep) {
maximum
} else initialMillisecondsToSleep
Thread.sleep(finalMillisecondsToSleep)
info(s"$errorContext. Request failed with the following message: ${ex.getMessage}. Retrying request.")
retryRequest(request, errorContext, errorCode, retriesLeft - 1, retriesAttempted + 1)
} else {
Left(SonatypeException(errorCode, ex.getMessage))
}
case Right(res) => Right(res)
}
} yield finalResponse
}
def uploadBundle(
localBundlePath: File,
deploymentName: DeploymentName,
publishingType: Option[PublishingType]
): Either[SonatypeException, DeploymentId] = {
info(s"Uploading bundle ${localBundlePath.getPath} to Sonatype Central")

retryRequest(
client.uploadBundle(localBundlePath, deploymentName, publishingType).body,
"Error uploading bundle to Sonatype Central",
BUNDLE_UPLOAD_FAILURE,
60
)
}

def didDeploySucceed(
deploymentId: DeploymentId,
shouldDeployBePublished: Boolean
): Either[SonatypeException, Boolean] = {

for {
response <- retryRequest(
client.checkStatus(deploymentId)(asJson[CheckStatusResponse]).body,
"Error checking deployment status",
STATUS_CHECK_FAILURE,
10
)
finalRes <-
if (response.deploymentState.isNonFinal) {
Thread.sleep(5000L)
didDeploySucceed(deploymentId, shouldDeployBePublished)
} else if (response.deploymentState == DeploymentState.FAILED) {
error(
s"Deployment failed for deployment id: ${deploymentId.unapply}. Current deployment state: ${response.deploymentState.unapply}"
)
Right(false)
} else if (response.deploymentState != PUBLISHED && shouldDeployBePublished) {
Thread.sleep(5000L)
didDeploySucceed(deploymentId, shouldDeployBePublished)
} else {
info(
s"Deployment succeeded for deployment id: ${deploymentId.unapply}. Current deployment state: ${response.deploymentState.unapply}"
)
Right(true)
}
} yield finalRes
}

override def close(): Unit = client.close()
}

object SonatypeCentralClient {
val host: String = "central.sonatype.com"

def fromCredentials(credentials: Seq[Credentials]): Either[SonatypeException, SonatypeCentralClient] =
for {
sonatypeCredentials <- SonatypeCredentials.fromEnv(credentials, host)
backend = Slf4jLoggingBackend(HttpURLConnectionBackend())
client = new SyncSonatypeClient(
sonatypeCredentials.toSonatypeCentralCredentials,
backend,
Some(LoggingOptions(logRequestBody = Some(true), logResponseBody = Some(true)))
)
} yield new SonatypeCentralClient(client)
}
81 changes: 81 additions & 0 deletions src/main/scala/xerial/sbt/sonatype/SonatypeCentralService.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package xerial.sbt.sonatype

import com.lumidion.sonatype.central.client.core.{DeploymentName, PublishingType}
import java.io.{File, FileInputStream, FileOutputStream}
import java.nio.file.{Files, Path}
import java.util.zip.{ZipEntry, ZipOutputStream}
import scala.util.Try
import wvlet.log.LogSupport
import xerial.sbt.sonatype.utils.Extensions.*
import xerial.sbt.sonatype.SonatypeException.{BUNDLE_ZIP_ERROR, STAGE_FAILURE}

private[sbt] class SonatypeCentralService(client: SonatypeCentralClient) extends LogSupport {

def uploadBundle(
localBundlePath: File,
deploymentName: DeploymentName,
publishingType: PublishingType
): Either[SonatypeException, Unit] = for {
bundleZipDirectory <- Try(Files.createDirectory(Path.of(s"${localBundlePath.getPath}-bundle"))).toEither.leftMap {
err =>
SonatypeException(BUNDLE_ZIP_ERROR, s"Error creating bundle zip directory. ${err.getMessage}")
}
zipFile <- Try(zipDirectory(localBundlePath, bundleZipDirectory)).toEither.leftMap { err =>
SonatypeException(BUNDLE_ZIP_ERROR, err.getMessage)
}
deploymentId <- client.uploadBundle(zipFile, deploymentName, Some(publishingType))
_ = info(s"Checking if deployment succeeded for deployment id: ${deploymentId.unapply}...")
didDeploySucceed <- client.didDeploySucceed(deploymentId, publishingType == PublishingType.AUTOMATIC)
_ <- Either.cond(
didDeploySucceed,
(),
SonatypeException(
STAGE_FAILURE,
s"Deployment failed. Deployment id: ${deploymentId.unapply}. Deployment name: ${deploymentName.unapply}"
)
)
} yield ()

private def zipDirectory(localBundlePath: File, bundleZipDirPath: Path): File = {
val outputZipFilePath = s"${bundleZipDirPath.toFile.getPath}/bundle.zip"
val fileOutputStream = new FileOutputStream(outputZipFilePath)
val zipOutputStream = new ZipOutputStream(fileOutputStream)
zipFile(localBundlePath, localBundlePath.getName, zipOutputStream, isDirTopLevel = true)
zipOutputStream.close()
fileOutputStream.close()

new File(outputZipFilePath)
}

private def zipFile(fileToZip: File, fileName: String, zipOut: ZipOutputStream, isDirTopLevel: Boolean): Unit = {
if (fileToZip.isHidden) return
if (fileToZip.isDirectory) {
if (!isDirTopLevel) {
if (fileName.endsWith("/")) {
zipOut.putNextEntry(new ZipEntry(fileName))
zipOut.closeEntry()
} else {
zipOut.putNextEntry(new ZipEntry(fileName + "/"))
zipOut.closeEntry()
}
}
val children = fileToZip.listFiles
val directoryPath = if (isDirTopLevel) {
""
} else fileName + "/"
for (childFile <- children) {
zipFile(childFile, directoryPath + childFile.getName, zipOut, isDirTopLevel = false)
}
return
}
val fileInputStream = new FileInputStream(fileToZip)
val zipEntry = new ZipEntry(fileName)
zipOut.putNextEntry(zipEntry)
val bytes = new Array[Byte](1024)
var length = 0
while ({ length = fileInputStream.read(bytes); length } >= 0) {
zipOut.write(bytes, 0, length)
}
fileInputStream.close()
}
}
57 changes: 16 additions & 41 deletions src/main/scala/xerial/sbt/sonatype/SonatypeClient.scala
Original file line number Diff line number Diff line change
@@ -1,28 +1,21 @@
package xerial.sbt.sonatype

import java.io.{File, IOException}
import java.net.URI
import java.util.concurrent.TimeUnit
import org.apache.http.auth.{AuthScope, UsernamePasswordCredentials}
import org.apache.http.impl.client.BasicCredentialsProvider
import org.sonatype.spice.zapper.ParametersBuilder
import org.sonatype.spice.zapper.client.hc4.Hc4ClientBuilder
import sbt.librarymanagement.ivy.{Credentials, DirectCredentials}
import org.sonatype.spice.zapper.ParametersBuilder
import sbt.librarymanagement.ivy.Credentials
import scala.concurrent.duration.Duration
import wvlet.airframe.control.{Control, ResultClass, Retry}
import wvlet.airframe.http.*
import wvlet.airframe.http.client.URLConnectionClientBackend
import wvlet.airframe.http.HttpHeader.MediaType
import wvlet.airframe.http.HttpMessage.Response
import wvlet.airframe.http.client.URLConnectionClientBackend
import wvlet.log.LogSupport
import xerial.sbt.sonatype.SonatypeException.{
BUNDLE_UPLOAD_FAILURE,
MISSING_CREDENTIAL,
STAGE_FAILURE,
STAGE_IN_PROGRESS
}

import java.io.{File, IOException}
import java.nio.charset.StandardCharsets
import java.util.Base64
import java.util.concurrent.TimeUnit
import scala.concurrent.duration.Duration
import xerial.sbt.sonatype.SonatypeException.{BUNDLE_UPLOAD_FAILURE, STAGE_FAILURE, STAGE_IN_PROGRESS}

/** REST API Client for Sonatype API (nexus-staging)
* https://repository.sonatype.org/nexus-staging-plugin/default/docs/rest.html
@@ -35,31 +28,13 @@ class SonatypeClient(
) extends AutoCloseable
with LogSupport {

private lazy val directCredentials = {
val credt: DirectCredentials = Credentials
.forHost(cred, credentialHost)
.getOrElse {
throw SonatypeException(
MISSING_CREDENTIAL,
s"No credential is found for ${credentialHost}. Prepare ~/.sbt/(sbt_version)/sonatype.sbt file."
)
}
credt
}
private lazy val sonatypeCredentials = SonatypeCredentials.fromEnvOrError(cred, credentialHost)

private lazy val base64Credentials = {
val credt = directCredentials
Base64.getEncoder.encodeToString(s"${credt.userName}:${credt.passwd}".getBytes(StandardCharsets.UTF_8))
}
private lazy val base64Credentials = sonatypeCredentials.toBase64

lazy val repoUri = {
def repoBase(url: String) = if (url.endsWith("/")) url.dropRight(1) else url
val url = repoBase(repositoryUrl)
url
}
private val pathPrefix = {
new java.net.URL(repoUri).getPath
}
lazy val repoUri: URI = URI.create(repositoryUrl.stripSuffix("/"))

private val pathPrefix = repoUri.getPath

private[sonatype] val clientConfig = {
Http.client
@@ -87,13 +62,13 @@ class SonatypeClient(
}
}

private[sonatype] val httpClient = clientConfig.newSyncClient(repoUri)
private[sonatype] val httpClient = clientConfig.newSyncClient(repoUri.toString)

// Create stage is not idempotent, so we just need to wait for a long time without retry
private val httpClientForCreateStage =
clientConfig
.withRetryContext(_.noRetry)
.newSyncClient(repoUri)
.newSyncClient(repoUri.toString)

override def close(): Unit = {
Control.closeResources(httpClient, httpClientForCreateStage)
@@ -285,7 +260,7 @@ class SonatypeClient(

val credentialProvider = new BasicCredentialsProvider()
val usernamePasswordCredentials =
new UsernamePasswordCredentials(directCredentials.userName, directCredentials.passwd)
new UsernamePasswordCredentials(sonatypeCredentials.userName, sonatypeCredentials.password)

credentialProvider.setCredentials(AuthScope.ANY, usernamePasswordCredentials)

36 changes: 36 additions & 0 deletions src/main/scala/xerial/sbt/sonatype/SonatypeCredentials.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package xerial.sbt.sonatype

import com.lumidion.sonatype.central.client.core.{SonatypeCredentials => SonatypeCentralCredentials}
import java.nio.charset.StandardCharsets
import java.util.Base64
import sbt.librarymanagement.ivy.Credentials
import xerial.sbt.sonatype.utils.Extensions.*
import xerial.sbt.sonatype.SonatypeException.MISSING_CREDENTIAL

private[sonatype] final case class SonatypeCredentials private (userName: String, password: String) {
override def toString: String = "SonatypeCredentials(userName: <redacted>, password: <redacted>)"

def toBase64: String = Base64.getEncoder.encodeToString(s"${userName}:${password}".getBytes(StandardCharsets.UTF_8))

def toSonatypeCentralCredentials: SonatypeCentralCredentials = SonatypeCentralCredentials(userName, password)
}

object SonatypeCredentials {
def fromEnv(
credentials: Seq[Credentials],
credentialHost: String
): Either[SonatypeException, SonatypeCredentials] = {
Credentials
.forHost(credentials, credentialHost)
.toRight {
SonatypeException(
MISSING_CREDENTIAL,
s"No credential is found for ${credentialHost}. Prepare ~/.sbt/(sbt_version)/sonatype.sbt file."
)
}
.map(directCredentials => SonatypeCredentials(directCredentials.userName, directCredentials.passwd))
}

def fromEnvOrError(credentials: Seq[Credentials], credentialHost: String): SonatypeCredentials =
fromEnv(credentials, credentialHost).getOrError
}
22 changes: 21 additions & 1 deletion src/main/scala/xerial/sbt/sonatype/SonatypeException.scala
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package xerial.sbt.sonatype

import xerial.sbt.Sonatype

/** An exception used for showing only an error message when there is no need to show stack traces
*/
case class SonatypeException(errorCode: ErrorCode, message: String) extends Exception(message) {
@@ -10,17 +12,35 @@ sealed trait ErrorCode

object SonatypeException {

case object USER_ERROR extends ErrorCode

case object BUNDLE_ZIP_ERROR extends ErrorCode

case object GENERIC_ERROR extends ErrorCode

case object JSON_PARSING_ERROR extends ErrorCode

case object STAGE_IN_PROGRESS extends ErrorCode

case object STAGE_FAILURE extends ErrorCode

case object STATUS_CHECK_FAILURE extends ErrorCode

case object BUNDLE_UPLOAD_FAILURE extends ErrorCode

case object MISSING_CREDENTIAL extends ErrorCode

case object MISSING_STAGING_PROFILE extends ErrorCode

case object MISSING_PROFILE extends ErrorCode
case class MISSING_PROFILE(profileName: String, host: String) extends ErrorCode {
def problem = s"Profile ${profileName} is not found on ${host}"
def possibleAlternativeHosts: Seq[String] = Sonatype.knownOssHosts.filterNot(_ == host)
def hostAdvice = s"try ${possibleAlternativeHosts.mkString(", or ")}?"
def advice: String =
s"In your sbt settings, check your sonatypeProfileName and sonatypeCredentialHost ($hostAdvice)"

def message: String = s"${problem}. ${advice}"
}

case object UNKNOWN_STAGE extends ErrorCode

14 changes: 5 additions & 9 deletions src/main/scala/xerial/sbt/sonatype/SonatypeService.scala
Original file line number Diff line number Diff line change
@@ -1,16 +1,14 @@
package xerial.sbt.sonatype

import org.xerial.sbt.sonatype.BuildInfo

import java.io.File
import org.xerial.sbt.sonatype.BuildInfo
import sbt.io.IO
import scala.util.Try
import wvlet.airframe.codec.MessageCodecFactory
import wvlet.log.LogSupport
import xerial.sbt.sonatype.SonatypeClient.*
import xerial.sbt.sonatype.SonatypeException.{MISSING_PROFILE, MISSING_STAGING_PROFILE, MULTIPLE_TARGETS, UNKNOWN_STAGE}

import scala.util.Try

/** Interface to access the REST API of Nexus
* @param profileName
*/
@@ -20,7 +18,7 @@ class SonatypeService(
cacheToken: Option[String]
) extends LogSupport
with AutoCloseable {
import SonatypeService._
import SonatypeService.*

def this(sonatypClient: SonatypeClient, profileName: String) = this(sonatypClient, profileName, None)

@@ -151,10 +149,8 @@ class SonatypeService(
lazy val currentProfile: StagingProfile = {
val profiles = stagingProfiles
if (profiles.isEmpty) {
throw SonatypeException(
MISSING_PROFILE,
s"Profile ${profileName} is not found. Check your sonatypeProfileName setting in build.sbt"
)
val error = MISSING_PROFILE(profileName, sonatypClient.repoUri.getHost)
throw SonatypeException(error, error.message)
}
profiles.head
}
16 changes: 16 additions & 0 deletions src/main/scala/xerial/sbt/sonatype/utils/Extensions.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package xerial.sbt.sonatype.utils

import xerial.sbt.sonatype.SonatypeException

private[sbt] object Extensions {
implicit class EitherOps[A, B](either: Either[A, B]) {
def leftMap[C](func: A => C): Either[C, B] = either match {
case Left(left) => Left(func(left))
case Right(right) => Right(right)
}
}

implicit class EitherSonatypeExceptionOps[A](either: Either[SonatypeException, A]) {
def getOrError: A = either.fold(ex => throw ex, identity)
}
}
17 changes: 17 additions & 0 deletions src/test/scala/xerial/sbt/sonatype/SonatypeExceptionTest.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package xerial.sbt.sonatype

import wvlet.airspec.AirSpec
import xerial.sbt.sonatype.SonatypeException.MISSING_PROFILE;

class SonatypeExceptionTest extends AirSpec {
test("give helpful advice when no profile is found") {
val missingProfile = MISSING_PROFILE("com.gu", "oss.sonatype.org")
missingProfile.problem shouldBe "Profile com.gu is not found on oss.sonatype.org"
missingProfile.possibleAlternativeHosts shouldBe Seq("s01.oss.sonatype.org")
missingProfile.advice shouldBe
"In your sbt settings, check your sonatypeProfileName and sonatypeCredentialHost (try s01.oss.sonatype.org?)"

MISSING_PROFILE("com.gu", "s01.oss.sonatype.org").hostAdvice shouldBe "try oss.sonatype.org?"
MISSING_PROFILE("com.gu", "example.com").hostAdvice shouldBe "try oss.sonatype.org, or s01.oss.sonatype.org?"
}
}