├── version.sbt ├── project ├── build.properties └── plugins.sbt ├── src ├── sbt-test │ └── sbt-sonatype │ │ ├── sonatype-central-host │ │ ├── test │ │ ├── project │ │ │ └── plugins.sbt │ │ └── build.sbt │ │ ├── example │ │ ├── test │ │ ├── project │ │ │ └── plugins.sbt │ │ └── build.sbt │ │ └── operations │ │ ├── test │ │ ├── project │ │ └── plugins.sbt │ │ └── build.sbt ├── main │ ├── scala-3 │ │ └── xerial │ │ │ └── sbt │ │ │ └── SonatypeCompat.scala │ ├── scala-2 │ │ └── xerial │ │ │ └── sbt │ │ │ └── SonatypeCompat.scala │ └── scala │ │ └── xerial │ │ └── sbt │ │ ├── sonatype │ │ ├── utils │ │ │ └── Extensions.scala │ │ ├── SonatypeCredentials.scala │ │ ├── SonatypeException.scala │ │ ├── SonatypeCentralService.scala │ │ ├── SonatypeCentralClient.scala │ │ ├── SonatypeService.scala │ │ └── SonatypeClient.scala │ │ └── Sonatype.scala └── test │ ├── scala │ └── xerial │ │ └── sbt │ │ └── sonatype │ │ ├── SonatypeExceptionTest.scala │ │ └── SonatypeClientTest.scala │ └── resources │ └── profiles.json ├── .scala-steward.conf ├── .gitignore ├── .scalafmt.conf ├── .git-blame-ignore-revs ├── .github ├── workflows │ ├── release-note.yml │ ├── release-drafter.yml │ └── test.yml ├── dependabot.yml ├── release.yml ├── ISSUE_TEMPLATE │ └── bug_report.md └── release-drafter.yml ├── sonatype.sbt ├── .mergify.yml ├── ReleaseNotes.md ├── LICENSE.txt ├── workflow.md ├── README.md └── sbt /version.sbt: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.10.7 2 | -------------------------------------------------------------------------------- /src/sbt-test/sbt-sonatype/sonatype-central-host/test: -------------------------------------------------------------------------------- 1 | > checkSonatypeDefaultResolver 2 | -------------------------------------------------------------------------------- /src/sbt-test/sbt-sonatype/example/test: -------------------------------------------------------------------------------- 1 | # Can't really test much, just test the project loads, and that the plugin is enabled 2 | > sonatypeDefaultResolver -------------------------------------------------------------------------------- /src/sbt-test/sbt-sonatype/operations/test: -------------------------------------------------------------------------------- 1 | # Can't really test much, just test the project loads, and that the plugin is enabled 2 | > sonatypeDefaultResolver -------------------------------------------------------------------------------- /.scala-steward.conf: -------------------------------------------------------------------------------- 1 | # sbt plugins must use Scala 2.12.x 2 | updates.pin = [ { groupId = "org.scala-lang", artifactId="scala-library", version = "2.12." } ] 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .lib 2 | *~ 3 | *.class 4 | *.log 5 | \#* 6 | 7 | # sbt specific 8 | dist/* 9 | target/ 10 | lib_managed/ 11 | src_managed/ 12 | project/boot/ 13 | project/plugins/project/ 14 | 15 | # Scala-IDE specific 16 | .scala_dependencies 17 | .idea* 18 | .bsp 19 | .vscode/ 20 | .bloop/ 21 | .metals/ 22 | metals.sbt 23 | 24 | src/sbt-test/*/*/bin/.lib 25 | 26 | -------------------------------------------------------------------------------- /src/main/scala-3/xerial/sbt/SonatypeCompat.scala: -------------------------------------------------------------------------------- 1 | package xerial.sbt.sonatype 2 | 3 | import wvlet.airframe.codec.MessageCodec 4 | import wvlet.airframe.codec.MessageCodecFactory 5 | 6 | private[sonatype] trait SonatypeCompat { self: SonatypeService => 7 | inline given codecInstance[A]: MessageCodec[A] = 8 | MessageCodecFactory.defaultFactoryForJSON.of[A] 9 | } 10 | -------------------------------------------------------------------------------- /.scalafmt.conf: -------------------------------------------------------------------------------- 1 | version = 3.8.4 2 | project.layout = StandardConvention 3 | runner.dialect = scala212 4 | maxColumn = 120 5 | style = defaultWithAlign 6 | optIn.breaksInsideChains = true 7 | rewrite.rules = [Imports] 8 | rewrite.imports.sort = original 9 | rewrite.imports.contiguousGroups = no 10 | rewrite.imports.groups = [["sbt.\\..*"], ["sttp.\\..*"], ["wvlet.\\..*"], ["xerial.sbt.\\..*"]] 11 | -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # Scala Steward: Reformat with scalafmt 3.7.0 2 | c3efd683f835329e057719c1f2ededac55589933 3 | 4 | # Scala Steward: Reformat with scalafmt 3.7.15 5 | 99aee8ec7e98cd260d674cca8f05505133e43d73 6 | 7 | # Scala Steward: Reformat with scalafmt 3.8.2 8 | d2b649ac01679f6b4a9482411665600e31209a43 9 | 10 | # Scala Steward: Reformat with scalafmt 3.8.4 11 | f8a1620a1fb7c589ed192c55af99e646562426b2 12 | -------------------------------------------------------------------------------- /src/main/scala-2/xerial/sbt/SonatypeCompat.scala: -------------------------------------------------------------------------------- 1 | package xerial.sbt.sonatype 2 | 3 | import scala.reflect.runtime.universe.TypeTag 4 | import wvlet.airframe.codec.MessageCodec 5 | import wvlet.airframe.codec.MessageCodecFactory 6 | 7 | private[sonatype] trait SonatypeCompat { self: SonatypeService => 8 | implicit def codecInstance[A: TypeTag]: MessageCodec[A] = 9 | MessageCodecFactory.defaultFactoryForJSON.of[A] 10 | } 11 | -------------------------------------------------------------------------------- /src/sbt-test/sbt-sonatype/example/project/plugins.sbt: -------------------------------------------------------------------------------- 1 | { 2 | val pluginVersion = System.getProperty("plugin.version") 3 | if (pluginVersion == null) 4 | throw new RuntimeException("""|The system property 'plugin.version' is not defined. 5 | |Specify this property using the scriptedLaunchOpts -D.""".stripMargin) 6 | else 7 | addSbtPlugin("org.xerial.sbt" % "sbt-sonatype" % pluginVersion) 8 | } 9 | -------------------------------------------------------------------------------- /src/sbt-test/sbt-sonatype/operations/project/plugins.sbt: -------------------------------------------------------------------------------- 1 | { 2 | val pluginVersion = System.getProperty("plugin.version") 3 | if (pluginVersion == null) 4 | throw new RuntimeException("""|The system property 'plugin.version' is not defined. 5 | |Specify this property using the scriptedLaunchOpts -D.""".stripMargin) 6 | else 7 | addSbtPlugin("org.xerial.sbt" % "sbt-sonatype" % pluginVersion) 8 | } 9 | -------------------------------------------------------------------------------- /src/sbt-test/sbt-sonatype/sonatype-central-host/project/plugins.sbt: -------------------------------------------------------------------------------- 1 | { 2 | val pluginVersion = System.getProperty("plugin.version") 3 | if (pluginVersion == null) 4 | throw new RuntimeException("""|The system property 'plugin.version' is not defined. 5 | |Specify this property using the scriptedLaunchOpts -D.""".stripMargin) 6 | else 7 | addSbtPlugin("org.xerial.sbt" % "sbt-sonatype" % pluginVersion) 8 | } 9 | -------------------------------------------------------------------------------- /src/sbt-test/sbt-sonatype/sonatype-central-host/build.sbt: -------------------------------------------------------------------------------- 1 | sonatypeCredentialHost := xerial.sbt.sonatype.SonatypeCentralClient.host 2 | 3 | val checkSonatypeDefaultResolver = taskKey[Unit]("Check if the default resolver is Sonatype") 4 | checkSonatypeDefaultResolver := { 5 | val actual = sonatypeDefaultResolver.value 6 | val expected = Resolver.url("https://central.sonatype.com") 7 | require(actual == expected, s"expected $actual to be $expected") 8 | } 9 | -------------------------------------------------------------------------------- /.github/workflows/release-note.yml: -------------------------------------------------------------------------------- 1 | name: Release Note 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | workflow_dispatch: 8 | 9 | jobs: 10 | release: 11 | name: Create a new release note 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Create a release note 15 | env: 16 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 17 | run: | 18 | gh release create "$GITHUB_REF_NAME" --repo="$GITHUB_REPOSITORY" --generate-notes 19 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | val SONATYPE_VERSION = sys.env.getOrElse("SONATYPE_VERSION", "3.12.2") 2 | addSbtPlugin("org.xerial.sbt" % "sbt-sonatype" % SONATYPE_VERSION) 3 | addSbtPlugin("com.github.sbt" % "sbt-pgp" % "2.3.0") 4 | addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.2") 5 | addSbtPlugin("com.github.sbt" % "sbt-dynver" % "5.1.0") 6 | addSbtPlugin("com.eed3si9n" % "sbt-buildinfo" % "0.13.1") 7 | 8 | libraryDependencies += "org.scala-sbt" %% "scripted-plugin" % sbtVersion.value 9 | 10 | resolvers += Resolver.sonatypeRepo("snapshots") 11 | -------------------------------------------------------------------------------- /sonatype.sbt: -------------------------------------------------------------------------------- 1 | import xerial.sbt.Sonatype._ 2 | 3 | publishMavenStyle := true 4 | 5 | sonatypeProfileName := "org.xerial" 6 | sonatypeProjectHosting := Some(GitHubHosting(user = "xerial", repository = "sbt-sonatype", email = "leo@xerial.org")) 7 | developers := List( 8 | Developer(id = "leo", name = "Taro L. Saito", email = "leo@xerial.org", url = url("http://xerial.org/leo")) 9 | ) 10 | licenses := Seq("APL2" -> url("http://www.apache.org/licenses/LICENSE-2.0.txt")) 11 | 12 | publishTo := sonatypePublishToBundle.value 13 | 14 | //sonatypeLogLevel := "DEBUG" 15 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "github-actions" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.mergify.yml: -------------------------------------------------------------------------------- 1 | pull_request_rules: 2 | - name: Automatic merge Scala Steward PRs 3 | conditions: 4 | - author=xerial-bot 5 | - check-success=Code Format 6 | - check-success=Test JDK11 7 | - check-success=Test JDK17 8 | - or: 9 | - title~=^Update airframe- 10 | - title~=^Update sbt-airframe 11 | - title~=^Update airspec 12 | - title~=^Update scalafmt-core 13 | - label=sbt-plugin-update 14 | - label=test-library-update 15 | - label=library-update 16 | actions: 17 | merge: 18 | method: squash 19 | -------------------------------------------------------------------------------- /src/main/scala/xerial/sbt/sonatype/utils/Extensions.scala: -------------------------------------------------------------------------------- 1 | package xerial.sbt.sonatype.utils 2 | 3 | import xerial.sbt.sonatype.SonatypeException 4 | 5 | private[sbt] object Extensions { 6 | implicit class EitherOps[A, B](either: Either[A, B]) { 7 | def leftMap[C](func: A => C): Either[C, B] = either match { 8 | case Left(left) => Left(func(left)) 9 | case Right(right) => Right(right) 10 | } 11 | } 12 | 13 | implicit class EitherSonatypeExceptionOps[A](either: Either[SonatypeException, A]) { 14 | def getOrError: A = either.fold(ex => throw ex, identity) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/sbt-test/sbt-sonatype/example/build.sbt: -------------------------------------------------------------------------------- 1 | organization := "org.xerial.example" 2 | sonatypeProfileName := "org.xerial" 3 | publishMavenStyle := true 4 | licenses := Seq("APL2" -> url("http://www.apache.org/licenses/LICENSE-2.0.txt")) 5 | homepage := Some(url("https://github.com/xerial/sbt-sonatype")) 6 | scmInfo := Some( 7 | ScmInfo( 8 | url("https://github.com/xerial/sbt-sonatype"), 9 | "scm:git@github.com:xerial/sbt-sonatype.git" 10 | ) 11 | ) 12 | developers := List( 13 | Developer(id = "leo", name = "Taro L. Saito", email = "leo@xerial.org", url = url("http://xerial.org/leo")) 14 | ) 15 | publishTo := sonatypePublishTo.value 16 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | 3 | on: 4 | # pull_request event is required only for autolabeler 5 | pull_request: 6 | # Only following types are handled by the action, but one can default to all as well 7 | types: [opened, reopened, synchronize] 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | update_release_draft: 14 | permissions: 15 | contents: read 16 | # write permission is required for autolabeler 17 | # otherwise, read permission is required at least 18 | pull-requests: write 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: release-drafter/release-drafter@v6 22 | env: 23 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 24 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | categories: 3 | - title: '🔥 Breaking Changes' 4 | labels: 5 | - 'breaking' 6 | - title: '👋 Deprecated' 7 | labels: 8 | - 'deprecation' 9 | - title: '🚀 Features' 10 | labels: 11 | - 'feature' 12 | - 'enhancement' 13 | - title: '🐛 Bug Fixes' 14 | labels: 15 | - 'bug' 16 | - title: '🔗 Dependency Updates' 17 | labels: 18 | - 'library-update' 19 | - 'dependencies' 20 | - title: '🛠 Internal Updates' 21 | labels: 22 | - 'internal' 23 | - 'kaizen' 24 | - 'test-library-update' 25 | - 'sbt-plugin-update' 26 | - title: '📚 Docs' 27 | labels: 28 | - 'doc' 29 | - title: Other Changes 30 | labels: 31 | - "*" 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A concise description of what the bug is. 12 | 13 | **How to reproduce the issue** 14 | Describe how to reproduce the issue and show the current configurations. 15 | 16 | - sbt version: (your sbt version) 17 | - sbt-sonatype version: (Describe your sbt-sonatype version. Using the latest version is always recommended) 18 | - Show your build settings: 19 | ``` 20 | $ sbt 21 | > sonatypeProfileName 22 | ... 23 | > sonatypeRepository 24 | ... 25 | ``` 26 | - GitHub repository: (Having a GitHub repository for reproducing the issue would be helpful) 27 | 28 | **Additional context** 29 | Add any other context about the problem here. 30 | -------------------------------------------------------------------------------- /src/test/scala/xerial/sbt/sonatype/SonatypeExceptionTest.scala: -------------------------------------------------------------------------------- 1 | package xerial.sbt.sonatype 2 | 3 | import wvlet.airspec.AirSpec 4 | import xerial.sbt.sonatype.SonatypeException.MISSING_PROFILE; 5 | 6 | class SonatypeExceptionTest extends AirSpec { 7 | test("give helpful advice when no profile is found") { 8 | val missingProfile = MISSING_PROFILE("com.gu", "oss.sonatype.org") 9 | missingProfile.problem shouldBe "Profile com.gu is not found on oss.sonatype.org" 10 | missingProfile.possibleAlternativeHosts shouldBe Seq("s01.oss.sonatype.org") 11 | missingProfile.advice shouldBe 12 | "In your sbt settings, check your sonatypeProfileName and sonatypeCredentialHost (try s01.oss.sonatype.org?)" 13 | 14 | MISSING_PROFILE("com.gu", "s01.oss.sonatype.org").hostAdvice shouldBe "try oss.sonatype.org?" 15 | MISSING_PROFILE("com.gu", "example.com").hostAdvice shouldBe "try oss.sonatype.org, or s01.oss.sonatype.org?" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/sbt-test/sbt-sonatype/operations/build.sbt: -------------------------------------------------------------------------------- 1 | organization := System.getProperty("organization", "org.xerial.operations") 2 | sonatypeProfileName := System.getProperty("profile.name", "org.xerial") 3 | version := System.getProperty("version", "0.1") 4 | sonatypeCredentialHost := System.getProperty("host", "oss.sonatype.org") 5 | sonatypeRepository := System.getProperty("repo", s"https${sonatypeRepository.value}//service/local") 6 | 7 | pomExtra := ( 8 | https://github.com/xerial/sbt-sonatype 9 | 10 | 11 | Apache 2 12 | http://www.apache.org/licenses/LICENSE-2.0.txt 13 | 14 | 15 | 16 | scm:git:github.com/xerial/sbt-sonatype.git 17 | scm:git:git@github.com:xerial/sbt-sonatype.git 18 | github.com/xerial/sbt-sonatype.git 19 | 20 | 21 | 22 | leo 23 | Taro L. Saito 24 | http://xerial.org/leo 25 | 26 | 27 | ) 28 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - master 8 | 9 | jobs: 10 | code_format: 11 | name: Code Format 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - name: scalafmt 16 | run: ./sbt scalafmtSbtCheck "+ scalafmtCheckAll" 17 | test: 18 | name: Test 19 | strategy: 20 | fail-fast: false 21 | matrix: 22 | include: 23 | - os: ubuntu-latest 24 | distribution: zulu 25 | java: 8 26 | - os: ubuntu-latest 27 | distribution: temurin 28 | java: 11 29 | - os: ubuntu-latest 30 | distribution: temurin 31 | java: 17 32 | - os: windows-latest 33 | distribution: temurin 34 | java: 17 35 | runs-on: ${{ matrix.os }} 36 | steps: 37 | - uses: actions/checkout@v4 38 | - uses: actions/setup-java@v4 39 | with: 40 | distribution: "${{ matrix.distribution }}" 41 | java-version: "${{ matrix.java }}" 42 | - name: sbt scripted test 43 | run: ./sbt "+ Test/compile" "+ test" "+ scripted" 44 | -------------------------------------------------------------------------------- /src/test/scala/xerial/sbt/sonatype/SonatypeClientTest.scala: -------------------------------------------------------------------------------- 1 | package xerial.sbt.sonatype; 2 | 3 | import wvlet.airframe.codec.{JSONCodec, MessageCodec} 4 | import wvlet.airframe.msgpack.spi.MessagePack 5 | import wvlet.airspec.AirSpec 6 | import wvlet.log.io.IOUtil 7 | import xerial.sbt.sonatype.SonatypeClient.StagingProfileResponse; 8 | 9 | /** */ 10 | class SonatypeClientTest extends AirSpec { 11 | test("parse profile json") { 12 | val json = IOUtil.readAsString("/profiles.json") 13 | val codec = MessageCodec.of[StagingProfileResponse] 14 | val profile = codec.fromJson(json) 15 | profile.data.size shouldBe 2 16 | 17 | val p = MessagePack.newBufferPacker 18 | JSONCodec.pack(p, json) 19 | val msgpack = p.toByteArray 20 | 21 | val unpacked = codec.fromMsgPack(msgpack) 22 | unpacked.data.size shouldBe 2 23 | 24 | profile shouldBe unpacked 25 | } 26 | 27 | test("build client") { 28 | val client = new SonatypeClient("https://oss.sonatype.org/service/local/", Seq.empty, "") 29 | client.httpClient 30 | } 31 | 32 | // test("create client") { 33 | // val client = new SonatypeClient("https://httpbin.org/", Seq.empty, "") 34 | // client.httpClient.readAs[Json](Http.GET("/status/500")) 35 | // } 36 | } 37 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name-template: 'v$RESOLVED_VERSION' 2 | tag-template: 'v$RESOLVED_VERSION' 3 | categories: 4 | - title: '🔥 Breaking Changes' 5 | labels: 6 | - 'breaking' 7 | - title: '🚀 Features' 8 | labels: 9 | - 'feature' 10 | - 'enhancement' 11 | - title: '🐛 Bug Fixes' 12 | labels: 13 | - 'bug' 14 | - title: '👋 Deprecated' 15 | labels: 16 | - 'deprecation' 17 | - title: '🔗 Dependency Updates' 18 | labels: 19 | - 'library-update' 20 | - 'dependencies' 21 | - title: '🛠 Internal Updates' 22 | labels: 23 | - 'internal' 24 | - 'kaizen' 25 | - 'test-library-update' 26 | - 'sbt-plugin-update' 27 | - title: '📚 Docs' 28 | labels: 29 | - 'doc' 30 | change-template: '- $TITLE @$AUTHOR (#$NUMBER)' 31 | 32 | template: | 33 | ## What's Changed 34 | $CHANGES 35 | **Full Changelog**: https://github.com/$OWNER/$REPOSITORY/compare/$PREVIOUS_TAG...v$RESOLVED_VERSION 36 | 37 | autolabeler: 38 | - label: 'doc' 39 | files: 40 | - '*.md' 41 | - label: 'bug' 42 | title: 43 | - '/fix/i' 44 | - label: 'internal' 45 | title: 46 | - '/internal/i' 47 | - label: 'deprecation' 48 | title: 49 | - '/deprecate/i' 50 | -------------------------------------------------------------------------------- /src/main/scala/xerial/sbt/sonatype/SonatypeCredentials.scala: -------------------------------------------------------------------------------- 1 | package xerial.sbt.sonatype 2 | 3 | import com.lumidion.sonatype.central.client.core.{SonatypeCredentials => SonatypeCentralCredentials} 4 | import java.nio.charset.StandardCharsets 5 | import java.util.Base64 6 | import sbt.librarymanagement.ivy.Credentials 7 | import xerial.sbt.sonatype.utils.Extensions.* 8 | import xerial.sbt.sonatype.SonatypeException.MISSING_CREDENTIAL 9 | 10 | private[sonatype] final case class SonatypeCredentials private (userName: String, password: String) { 11 | override def toString: String = "SonatypeCredentials(userName: , password: )" 12 | 13 | def toBase64: String = Base64.getEncoder.encodeToString(s"${userName}:${password}".getBytes(StandardCharsets.UTF_8)) 14 | 15 | def toSonatypeCentralCredentials: SonatypeCentralCredentials = SonatypeCentralCredentials(userName, password) 16 | } 17 | 18 | object SonatypeCredentials { 19 | def fromEnv( 20 | credentials: Seq[Credentials], 21 | credentialHost: String 22 | ): Either[SonatypeException, SonatypeCredentials] = { 23 | Credentials 24 | .forHost(credentials, credentialHost) 25 | .toRight { 26 | SonatypeException( 27 | MISSING_CREDENTIAL, 28 | s"No credential is found for ${credentialHost}. Prepare ~/.sbt/(sbt_version)/sonatype.sbt file." 29 | ) 30 | } 31 | .map(directCredentials => SonatypeCredentials(directCredentials.userName, directCredentials.passwd)) 32 | } 33 | 34 | def fromEnvOrError(credentials: Seq[Credentials], credentialHost: String): SonatypeCredentials = 35 | fromEnv(credentials, credentialHost).getOrError 36 | } 37 | -------------------------------------------------------------------------------- /src/main/scala/xerial/sbt/sonatype/SonatypeException.scala: -------------------------------------------------------------------------------- 1 | package xerial.sbt.sonatype 2 | 3 | import xerial.sbt.Sonatype 4 | 5 | /** An exception used for showing only an error message when there is no need to show stack traces 6 | */ 7 | case class SonatypeException(errorCode: ErrorCode, message: String) extends Exception(message) { 8 | override def toString = s"[${errorCode}] ${message}" 9 | } 10 | 11 | sealed trait ErrorCode 12 | 13 | object SonatypeException { 14 | 15 | case object USER_ERROR extends ErrorCode 16 | 17 | case object BUNDLE_ZIP_ERROR extends ErrorCode 18 | 19 | case object GENERIC_ERROR extends ErrorCode 20 | 21 | case object JSON_PARSING_ERROR extends ErrorCode 22 | 23 | case object STAGE_IN_PROGRESS extends ErrorCode 24 | 25 | case object STAGE_FAILURE extends ErrorCode 26 | 27 | case object STATUS_CHECK_FAILURE extends ErrorCode 28 | 29 | case object BUNDLE_UPLOAD_FAILURE extends ErrorCode 30 | 31 | case object MISSING_CREDENTIAL extends ErrorCode 32 | 33 | case object MISSING_STAGING_PROFILE extends ErrorCode 34 | 35 | case class MISSING_PROFILE(profileName: String, host: String) extends ErrorCode { 36 | def problem = s"Profile ${profileName} is not found on ${host}" 37 | def possibleAlternativeHosts: Seq[String] = Sonatype.knownOssHosts.filterNot(_ == host) 38 | def hostAdvice = s"try ${possibleAlternativeHosts.mkString(", or ")}?" 39 | def advice: String = 40 | s"In your sbt settings, check your sonatypeProfileName and sonatypeCredentialHost ($hostAdvice)" 41 | 42 | def message: String = s"${problem}. ${advice}" 43 | } 44 | 45 | case object UNKNOWN_STAGE extends ErrorCode 46 | 47 | case object MULTIPLE_TARGETS extends ErrorCode 48 | 49 | } 50 | -------------------------------------------------------------------------------- /src/test/resources/profiles.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": [ 3 | { 4 | "resourceURI": "https://oss.sonatype.org/service/local/staging/profiles/1225bf61377734", 5 | "id": "1225bf61377734", 6 | "name": "org.wvlet", 7 | "repositoryTemplateId": "default_hosted_release", 8 | "repositoryType": "maven2", 9 | "repositoryTargetId": "1225bcd6c6b785", 10 | "inProgress": false, 11 | "order": 15227, 12 | "deployURI": "https://oss.sonatype.org/service/local/staging/deploy/maven2", 13 | "targetGroups": [ 14 | "staging" 15 | ], 16 | "finishNotifyRoles": [], 17 | "promotionNotifyRoles": [], 18 | "dropNotifyRoles": [], 19 | "closeRuleSets": [ 20 | "5e9e8e6f8d20a3" 21 | ], 22 | "promoteRuleSets": [], 23 | "promotionTargetRepository": "releases", 24 | "mode": "BOTH", 25 | "finishNotifyCreator": true, 26 | "promotionNotifyCreator": true, 27 | "dropNotifyCreator": true, 28 | "autoStagingDisabled": false, 29 | "repositoriesSearchable": true, 30 | "properties": { 31 | "@class": "linked-hash-map" 32 | } 33 | }, 34 | { 35 | "resourceURI": "https://oss.sonatype.org/service/local/staging/profiles/7edbe315063867", 36 | "id": "7edbe315063867", 37 | "name": "Central Bundles", 38 | "repositoryTemplateId": "default_hosted_release", 39 | "repositoryType": "maven2", 40 | "repositoryTargetId": "59f9f348209f28", 41 | "inProgress": false, 42 | "order": 35424, 43 | "deployURI": "https://oss.sonatype.org/service/local/staging/deploy/maven2", 44 | "targetGroups": [ 45 | "central-staging" 46 | ], 47 | "finishNotifyRoles": [], 48 | "promotionNotifyRoles": [], 49 | "dropNotifyRoles": [], 50 | "finishNotifyEmails": "central@sonatype.com", 51 | "promotionNotifyEmails": "central@sonatype.com", 52 | "dropNotifyEmails": "central@sonatype.com", 53 | "closeRuleSets": [ 54 | "5e9e8e6f8d20a3" 55 | ], 56 | "promoteRuleSets": [], 57 | "promotionTargetRepository": "central-sync", 58 | "mode": "BOTH", 59 | "finishNotifyCreator": false, 60 | "promotionNotifyCreator": false, 61 | "dropNotifyCreator": false, 62 | "autoStagingDisabled": true, 63 | "repositoriesSearchable": false, 64 | "properties": { 65 | "@class": "linked-hash-map" 66 | } 67 | } 68 | ] 69 | } 70 | -------------------------------------------------------------------------------- /src/main/scala/xerial/sbt/sonatype/SonatypeCentralService.scala: -------------------------------------------------------------------------------- 1 | package xerial.sbt.sonatype 2 | 3 | import com.lumidion.sonatype.central.client.core.{DeploymentName, PublishingType} 4 | import java.io.{File, FileInputStream, FileOutputStream} 5 | import java.nio.file.{Files, Path, Paths} 6 | import java.util.zip.{ZipEntry, ZipOutputStream} 7 | import scala.util.Try 8 | import wvlet.log.LogSupport 9 | import xerial.sbt.sonatype.utils.Extensions.* 10 | import xerial.sbt.sonatype.SonatypeException.{BUNDLE_ZIP_ERROR, STAGE_FAILURE} 11 | 12 | private[sbt] class SonatypeCentralService(client: SonatypeCentralClient) extends LogSupport { 13 | 14 | def uploadBundle( 15 | localBundlePath: File, 16 | deploymentName: DeploymentName, 17 | publishingType: PublishingType 18 | ): Either[SonatypeException, Unit] = for { 19 | bundleZipDirectory <- Try(Files.createDirectory(Paths.get(s"${localBundlePath.getPath}-bundle"))).toEither.leftMap { 20 | err => 21 | SonatypeException(BUNDLE_ZIP_ERROR, s"Error creating bundle zip directory. ${err.getMessage}") 22 | } 23 | zipFile <- Try(zipDirectory(localBundlePath, bundleZipDirectory)).toEither.leftMap { err => 24 | SonatypeException(BUNDLE_ZIP_ERROR, err.getMessage) 25 | } 26 | deploymentId <- client.uploadBundle(zipFile, deploymentName, Some(publishingType)) 27 | _ = info(s"Checking if deployment succeeded for deployment id: ${deploymentId.unapply}...") 28 | didDeploySucceed <- client.didDeploySucceed(deploymentId, publishingType == PublishingType.AUTOMATIC) 29 | _ <- Either.cond( 30 | didDeploySucceed, 31 | (), 32 | SonatypeException( 33 | STAGE_FAILURE, 34 | s"Deployment failed. Deployment id: ${deploymentId.unapply}. Deployment name: ${deploymentName.unapply}" 35 | ) 36 | ) 37 | } yield () 38 | 39 | private def zipDirectory(localBundlePath: File, bundleZipDirPath: Path): File = { 40 | val outputZipFilePath = s"${bundleZipDirPath.toFile.getPath}/bundle.zip" 41 | val fileOutputStream = new FileOutputStream(outputZipFilePath) 42 | val zipOutputStream = new ZipOutputStream(fileOutputStream) 43 | zipFile(localBundlePath, localBundlePath.getName, zipOutputStream, isDirTopLevel = true) 44 | zipOutputStream.close() 45 | fileOutputStream.close() 46 | 47 | new File(outputZipFilePath) 48 | } 49 | 50 | private def zipFile(fileToZip: File, fileName: String, zipOut: ZipOutputStream, isDirTopLevel: Boolean): Unit = { 51 | if (fileToZip.isHidden) return 52 | if (fileToZip.isDirectory) { 53 | if (!isDirTopLevel) { 54 | if (fileName.endsWith("/")) { 55 | zipOut.putNextEntry(new ZipEntry(fileName)) 56 | zipOut.closeEntry() 57 | } else { 58 | zipOut.putNextEntry(new ZipEntry(fileName + "/")) 59 | zipOut.closeEntry() 60 | } 61 | } 62 | val children = fileToZip.listFiles 63 | val directoryPath = if (isDirTopLevel) { 64 | "" 65 | } else fileName + "/" 66 | for (childFile <- children) { 67 | zipFile(childFile, directoryPath + childFile.getName, zipOut, isDirTopLevel = false) 68 | } 69 | return 70 | } 71 | val fileInputStream = new FileInputStream(fileToZip) 72 | val zipEntry = new ZipEntry(fileName) 73 | zipOut.putNextEntry(zipEntry) 74 | val bytes = new Array[Byte](1024) 75 | var length = 0 76 | while ({ length = fileInputStream.read(bytes); length } >= 0) { 77 | zipOut.write(bytes, 0, length) 78 | } 79 | fileInputStream.close() 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/main/scala/xerial/sbt/sonatype/SonatypeCentralClient.scala: -------------------------------------------------------------------------------- 1 | package xerial.sbt.sonatype 2 | 3 | import com.lumidion.sonatype.central.client.core.{ 4 | CheckStatusResponse, 5 | DeploymentId, 6 | DeploymentName, 7 | DeploymentState, 8 | PublishingType 9 | } 10 | import com.lumidion.sonatype.central.client.core.DeploymentState.PUBLISHED 11 | import com.lumidion.sonatype.central.client.sttp.core.SyncSonatypeClient 12 | import com.lumidion.sonatype.central.client.upickle.decoders.* 13 | import java.io.File 14 | import sbt.librarymanagement.ivy.Credentials 15 | import scala.math.pow 16 | import scala.util.Try 17 | import sttp.client4.{HttpError, ResponseException} 18 | import sttp.client4.httpurlconnection.HttpURLConnectionBackend 19 | import sttp.client4.logging.slf4j.Slf4jLoggingBackend 20 | import sttp.client4.logging.LoggingOptions 21 | import sttp.client4.upicklejson.default.* 22 | import sttp.model.StatusCode 23 | import wvlet.log.LogSupport 24 | import xerial.sbt.sonatype.utils.Extensions.* 25 | import xerial.sbt.sonatype.SonatypeException.{BUNDLE_UPLOAD_FAILURE, STATUS_CHECK_FAILURE, USER_ERROR} 26 | 27 | private[sonatype] class SonatypeCentralClient( 28 | client: SyncSonatypeClient, 29 | readTimeoutMillis: Long 30 | ) extends AutoCloseable 31 | with LogSupport { 32 | 33 | private def retryRequest[A, E]( 34 | request: => Either[ResponseException[String, E], A], 35 | errorContext: String, 36 | errorCode: ErrorCode, 37 | retriesLeft: Int, 38 | retriesAttempted: Int = 0 39 | ): Either[SonatypeException, A] = { 40 | for { 41 | response <- Try(request).toEither.leftMap { err => 42 | SonatypeException(errorCode, s"$errorContext. ${err.getMessage}") 43 | } 44 | finalResponse <- response match { 45 | case Left(HttpError(message, code)) 46 | if (code == StatusCode.Forbidden) || (code == StatusCode.Unauthorized) || (code == StatusCode.BadRequest) => 47 | Left( 48 | new SonatypeException(USER_ERROR, s"$errorContext. Status code: ${code.code}. Message Received: $message") 49 | ) 50 | case Left(ex) => 51 | if (retriesLeft > 0) { 52 | val exponent = pow(5, retriesAttempted).toInt 53 | val maximum = 30000 54 | val initialMillisecondsToSleep = 1500 + exponent 55 | val finalMillisecondsToSleep = if (maximum < initialMillisecondsToSleep) { 56 | maximum 57 | } else initialMillisecondsToSleep 58 | Thread.sleep(finalMillisecondsToSleep) 59 | info(s"$errorContext. Request failed with the following message: ${ex.getMessage}. Retrying request.") 60 | retryRequest(request, errorContext, errorCode, retriesLeft - 1, retriesAttempted + 1) 61 | } else { 62 | Left(SonatypeException(errorCode, ex.getMessage)) 63 | } 64 | case Right(res) => Right(res) 65 | } 66 | } yield finalResponse 67 | } 68 | def uploadBundle( 69 | localBundlePath: File, 70 | deploymentName: DeploymentName, 71 | publishingType: Option[PublishingType] 72 | ): Either[SonatypeException, DeploymentId] = { 73 | info(s"Uploading bundle ${localBundlePath.getPath} to Sonatype Central") 74 | 75 | retryRequest( 76 | client.uploadBundle(localBundlePath, deploymentName, publishingType).body, 77 | "Error uploading bundle to Sonatype Central", 78 | BUNDLE_UPLOAD_FAILURE, 79 | 60 80 | ) 81 | } 82 | 83 | def didDeploySucceed( 84 | deploymentId: DeploymentId, 85 | shouldDeployBePublished: Boolean 86 | ): Either[SonatypeException, Boolean] = { 87 | 88 | for { 89 | response <- retryRequest( 90 | client.checkStatus(deploymentId, timeout = readTimeoutMillis)(asJson[CheckStatusResponse]).body, 91 | "Error checking deployment status", 92 | STATUS_CHECK_FAILURE, 93 | 10 94 | ) 95 | finalRes <- 96 | if (response.deploymentState.isNonFinal) { 97 | Thread.sleep(5000L) 98 | didDeploySucceed(deploymentId, shouldDeployBePublished) 99 | } else if (response.deploymentState == DeploymentState.FAILED) { 100 | error( 101 | s"Deployment failed for deployment id: ${deploymentId.unapply}. Current deployment state: ${response.deploymentState.unapply}" 102 | ) 103 | Right(false) 104 | } else if (response.deploymentState != PUBLISHED && shouldDeployBePublished) { 105 | Thread.sleep(5000L) 106 | didDeploySucceed(deploymentId, shouldDeployBePublished) 107 | } else { 108 | info( 109 | s"Deployment succeeded for deployment id: ${deploymentId.unapply}. Current deployment state: ${response.deploymentState.unapply}" 110 | ) 111 | Right(true) 112 | } 113 | } yield finalRes 114 | } 115 | 116 | override def close(): Unit = client.close() 117 | } 118 | 119 | object SonatypeCentralClient { 120 | val host: String = "central.sonatype.com" 121 | 122 | def fromCredentials( 123 | credentials: Seq[Credentials], 124 | readTimeoutMillis: Long 125 | ): Either[SonatypeException, SonatypeCentralClient] = 126 | for { 127 | sonatypeCredentials <- SonatypeCredentials.fromEnv(credentials, host) 128 | backend = Slf4jLoggingBackend(HttpURLConnectionBackend()) 129 | client = new SyncSonatypeClient( 130 | sonatypeCredentials.toSonatypeCentralCredentials, 131 | backend, 132 | Some(LoggingOptions(logRequestBody = Some(true), logResponseBody = Some(true))) 133 | ) 134 | } yield new SonatypeCentralClient(client, readTimeoutMillis) 135 | } 136 | -------------------------------------------------------------------------------- /ReleaseNotes.md: -------------------------------------------------------------------------------- 1 | Release Notes 2 | === 3 | 4 | See [the release note page](https://github.com/xerial/sbt-sonatype/releases) for the latest release notes. 5 | 6 | # 3.9.14 7 | 8 | - Upgraded airframe-http to 22.11.0 9 | - Add more Sonatype API error handling 10 | - Upgrade to sbt 1.7.3 11 | - Use sbt-dynver for versioning 12 | 13 | # 3.9.13 14 | 15 | - Upgraded airframe-http to 22.5.0 to fix JDK17-specific error [#293](https://github.com/xerial/sbt-sonatype/issues/293) 16 | - Reverted the fix for #276 due to the regression 17 | 18 | 19 | # 3.9.12 20 | - DO NOT USE THIS VERSION. A regression, which failed to find a proper sonatype credential, is reported. Use 3.9.11 instead 21 | - A fix for [#276](https://github.com/xerial/sbt-sonatype/issues/276): Always use `ThisBuild / sonatypeCredentialHost` setting [#285](https://github.com/xerial/sbt-sonatype/pull/285) 22 | 23 | # 3.9.11 24 | - Use unique names for resolvers to support s01.oss.sonatype.org [#279](https://github.com/xerial/sbt-sonatype/pull/#279) 25 | - Increase the max retry to 100, timeoutMillis to 3 hours for big projects 26 | - [internal] Upgrade to airframe 22.1.0 27 | 28 | # 3.9.10 29 | - Improve the cache management to support s01.oss.sonatype.org 30 | - Internal: Upgrade to airframe 21.8.1 (no scala-parser-combinator dependency) 31 | 32 | # 3.9.9 33 | - Remove scala-parser-combinator 2.x dependency. This is a workaround for sbt-pgp, which still depends on scala-parser-combinators 1.x. 34 | 35 | # 3.9.8 36 | - Scope sonatypeCredentialHost to the build instead of projects. (#242) 37 | - internal: Upgrade to airframe 21.8.0 38 | 39 | # 3.9.7 40 | - 2021-03-12 41 | - Fixes a bug in releasing snapshot artifacts to the new s01.oss.sonatype.org repository. 42 | 43 | # 3.9.6 44 | - 2021-03-10 45 | - Support a new sonatype host with `sonatypeCredentialHost := "s01.oss.sonatype.org"` setting 46 | - internal: Upgrade to airframe 21.3.0 47 | 48 | # 3.9.5 49 | - 2020-11-02 50 | - Fixes an issue when different versions of sub modules exist [#197](https://github.com/xerial/sbt-sonatype/issues/197) 51 | 52 | # 3.9.4 53 | - 2020-07-03 54 | - Use URLConnection based HTTP client to remove the dependency to Finagle and Jackson 55 | - internal: Upgrade to airframe-http 20.6.2 56 | 57 | # 3.9.3 58 | - 2020-06-19 59 | - Fixes an issue when using `sbt "sonatypeReleaseAll (sonatypeProfileName)` standalone without build.sbt file. 60 | - Using GitHub Actions for CI 61 | 62 | # 3.9.2 63 | - 2020-03-27 64 | - Use a longer total timeout (upto 10 min.) for individual HTTP requests to address an issue like [#149](https://github.com/xerial/sbt-sonatype/issues/149) 65 | 66 | # 3.9.1 67 | - 2020-03-27 68 | - Adjust the wait interval for close/promote stages 69 | - Fixed a bug so as not to retry the create staging repository request 70 | 71 | # 3.9.0 72 | - 2020-03-26 73 | - __This version has an issue in creating staging repositories. Use 3.9.1__ 74 | - Add sonatypeTimeoutMillis to wait longer time until close/promote stage completes. The default is 60 minutes. 75 | - Changed the backend HTTP client to airframe-http-finagle 76 | - Use JSON REST API of Sonatype instead of handling XML 77 | - Add retry for each HTTP request and bundle upload 78 | - Use ANSI colors for logging messages 79 | - Improved error messages 80 | 81 | # 3.8.1 82 | - 2019-11-20 83 | - Fix for the credential resolver to support sbt-gpg [#124](https://github.com/xerial/sbt-sonatype/pull/124) 84 | 85 | # 3.8 86 | - 2019-09-27 87 | - Show log message periodically to avoid Travis CI 10 minutes timeout [#108](https://github.com/xerial/sbt-sonatype/issues/108) 88 | 89 | # 3.7 90 | - 2019-09-11 91 | - Upgrade http-client version to address NoClassDefFound error. 92 | 93 | # 3.6 94 | - 2019-09-06 95 | - Support dynamic versions generated by sbt-dynver. 96 | 97 | # 3.5 98 | - 2019-09-06 99 | - A minor fix for multi-module projects to use a single staging folder [#94](https://github.com/xerial/sbt-sonatype/pull/94) 100 | 101 | # 3.4 102 | - 2019-09-05 103 | - Added sonatypeBundleRelease as a short cut for `sonatypePrepare; sonatypeUploadBundle; sonatypeRelease` 104 | 105 | # 3.2 106 | - 2019-09-05 107 | - Re-built for sbt 1.2.8 108 | 109 | # 3.1 110 | - September 5, 2019 111 | - Support `sonatypeBundleUpload`, which makes uploading hundreds of artifacts much faster than directly uploading them to Sonatype. [#89](https://github.com/xerial/sbt-sonatype/issues/89) 112 | 113 | # 3.0 114 | - September 4, 2019 115 | - Support building idempotent release flow using `sonatypePrepare` 116 | - Support parallel artifact upload flows. See https://github.com/xerial/sbt-sonatype/#uploading-artifacts-in-parallel for the details. 117 | - Parallelize the processing of sonatypeReleaseAll and sonatypeDropAll 118 | - Drop `sontaypeList`. Use sonatypeStagingRepositoryProfiles instead 119 | - From this version, sbt-sonatype no longer overwrites `publishTo` settings automatically after `sonatypeOpen` command. Make sure setting `publishTo := sonatypePublishTo.value`. 120 | 121 | # 2.6 122 | - Added `sonatypeDropAll` 123 | 124 | # 2.5 125 | - Fixes issue #79 126 | 127 | # 2.4 128 | - Fixes a bug in propagating publishTo setting to sub projects [#76](https://github.com/xerial/sbt-sonatype/issues/76) 129 | - Drop sbt 0.13.x support 130 | 131 | # 2.3 132 | - Allow setting the credentials with the environment variables `SONATYPE_USERNAME` and `SONATYPE_PASSWORD` 133 | 134 | # 2.2 135 | - Fixes typo [#61](https://github.com/xerial/sbt-sonatype/pull/61) in GitHubHosting and GitLabHosting capitilization 136 | - If you are not using these keys, no need to upgrade to this version. 137 | 138 | # 2.1 139 | - Fixes [#55](https://github.com/xerial/sbt-sonatype/issues/55) with `sonatypePublishTo` setting: 140 | ```scala 141 | publishTo := sonatypePublishTo 142 | ``` 143 | - Add shortcut `sonatypeProjectHosting` for quickly setting `homepage`, `scmInfo` and `developers` 144 | 145 | 146 | # 2.0 147 | - Support sbt-0.13, 1.0.0-M5, 1.0.0-M6, and 1.0.0-RC3 148 | 149 | # 2.0.0-M1 150 | - Add sbt-1.0.0-M5 (for Scala 2.12), sbt-0.13.x support 151 | - **IMPORTANT** sbt-sonatype no longer modifies `publishTo` settings. You need to manually set the Sonatype repository name as below: 152 | ```scala 153 | publishTo := Some( 154 | if (isSnapshot.value) 155 | Opts.resolver.sonatypeSnapshots 156 | else 157 | Opts.resolver.sonatypeStaging 158 | ) 159 | ``` 160 | 161 | # 1.1 162 | - Add sonatypeOpen command for supporting more complex workflows (see the usage example in [workflow.md](workflow.md)) 163 | 164 | # 1.0 165 | - The first major release (stable) 166 | 167 | # 0.5.1 168 | - `sonatypeReleaseAll (sonatypeProfileName)` command can be used standalone without preparing sbt project files. 169 | 170 | # 0.5.0 171 | - sonatypeRelease etc. are now sbt Commands. 172 | - No need exists to include `sonatypeSettings`. This will be automcatically loaded 173 | 174 | # 0.4.0 175 | - Simplified the configuration for multi-module build 176 | - Migration guide for 0.3.x, 0.2.x users: Just include `Sonatype.sonatypeSettings` in `(project root)/sonatype.sbt` file. 177 | 178 | # 0.3.3 179 | - Retry request on 500 error from sonatype REST API 180 | - Improved log message 181 | 182 | # 0.3.0 183 | - sbt-sonatype is now an auto plugin 184 | - Migration guide from 0.2.x 185 | - `profileName` -> `sonatypeProfileName` 186 | - No need to include sonatypeSettings 187 | -------------------------------------------------------------------------------- /src/main/scala/xerial/sbt/sonatype/SonatypeService.scala: -------------------------------------------------------------------------------- 1 | package xerial.sbt.sonatype 2 | 3 | import java.io.File 4 | import org.xerial.sbt.sonatype.BuildInfo 5 | import sbt.io.IO 6 | import scala.util.Try 7 | import wvlet.airframe.codec.MessageCodec 8 | import wvlet.log.LogSupport 9 | import xerial.sbt.sonatype.SonatypeClient.* 10 | import xerial.sbt.sonatype.SonatypeException.{MISSING_PROFILE, MISSING_STAGING_PROFILE, MULTIPLE_TARGETS, UNKNOWN_STAGE} 11 | 12 | /** Interface to access the REST API of Nexus 13 | * @param profileName 14 | */ 15 | class SonatypeService( 16 | sonatypClient: SonatypeClient, 17 | val profileName: String, 18 | cacheToken: Option[String] 19 | ) extends LogSupport 20 | with SonatypeCompat 21 | with AutoCloseable { 22 | import SonatypeService.* 23 | 24 | def this(sonatypClient: SonatypeClient, profileName: String) = this(sonatypClient, profileName, None) 25 | 26 | info(s"sbt-sonatype version: ${BuildInfo.version}") 27 | info(s"sonatypeRepository : ${sonatypClient.repoUri}") 28 | info(s"sonatypeProfileName : ${profileName}") 29 | 30 | override def close(): Unit = { 31 | sonatypClient.close() 32 | } 33 | 34 | def findTargetRepository(command: CommandType, arg: Option[String]): StagingRepositoryProfile = { 35 | val repos = command match { 36 | case Close => openRepositories 37 | case Promote => closedRepositories 38 | case Drop => stagingRepositoryProfiles() 39 | case CloseAndPromote => stagingRepositoryProfiles() 40 | } 41 | if (repos.isEmpty) { 42 | if (stagingProfiles.isEmpty) { 43 | error(s"No staging profile found for $profileName") 44 | error("Have you requested a staging profile and successfully published your signed artifact there?") 45 | throw SonatypeException(MISSING_STAGING_PROFILE, s"No staging profile found for $profileName") 46 | } else { 47 | throw new IllegalStateException(command.errNotFound) 48 | } 49 | } 50 | 51 | def findSpecifiedInArg(target: String) = { 52 | repos.find(_.repositoryId == target).getOrElse { 53 | error(s"Repository $target is not found") 54 | error(s"Specify one of the repository ids in:\n${repos.mkString("\n")}") 55 | throw SonatypeException(UNKNOWN_STAGE, s"Repository $target is not found") 56 | } 57 | } 58 | 59 | arg.map(findSpecifiedInArg).getOrElse { 60 | if (repos.size > 1) { 61 | error(s"Multiple repositories are found:\n${repos.mkString("\n")}") 62 | error(s"Specify one of the repository ids in the command line or run sonatypeDropAll to cleanup repositories") 63 | throw SonatypeException(MULTIPLE_TARGETS, "Found multiple staging repositories") 64 | } else { 65 | repos.head 66 | } 67 | } 68 | } 69 | 70 | def openRepositories = stagingRepositoryProfiles().filter(_.isOpen).sortBy(_.repositoryId) 71 | def closedRepositories = stagingRepositoryProfiles().filter(_.isClosed).sortBy(_.repositoryId) 72 | 73 | def uploadBundle(localBundlePath: File, deployPath: String): Unit = { 74 | sonatypClient.uploadBundle(localBundlePath, deployPath) 75 | } 76 | 77 | def openOrCreateByKey(descriptionKey: String): StagingRepositoryProfile = { 78 | // Find the already opened profile or create a new one 79 | val repos = findStagingRepositoryProfilesWithKey(descriptionKey) 80 | if (repos.size > 1) { 81 | throw SonatypeException( 82 | MULTIPLE_TARGETS, 83 | s"Multiple staging repositories for ${descriptionKey} exists. Run sonatypeDropAll first to clean up old repositories" 84 | ) 85 | } else if (repos.size == 1) { 86 | val repo = repos.head 87 | info(s"Found a staging repository ${repo}") 88 | repo 89 | } else { 90 | // Create a new staging repository by appending [sbt-sonatype] prefix to its description so that we can find the repository id later 91 | info(s"No staging repository for ${descriptionKey} is found. Create a new one.") 92 | createStage(descriptionKey) 93 | } 94 | } 95 | 96 | def dropIfExistsByKey(descriptionKey: String): Option[StagingRepositoryProfile] = { 97 | // Drop the staging repository if exists 98 | val repos = findStagingRepositoryProfilesWithKey(descriptionKey) 99 | if (repos.isEmpty) { 100 | info(s"No previous staging repository for ${descriptionKey} was found") 101 | None 102 | } else { 103 | repos.map { repo => 104 | info(s"Found a previous staging repository ${repo}") 105 | dropStage(repo) 106 | }.lastOption 107 | } 108 | } 109 | 110 | def findStagingRepositoryProfilesWithKey(descriptionKey: String): Seq[StagingRepositoryProfile] = { 111 | stagingRepositoryProfiles(warnIfMissing = false).filter(_.description == descriptionKey) 112 | } 113 | 114 | def stagingRepositoryProfiles(warnIfMissing: Boolean = true): Seq[StagingRepositoryProfile] = { 115 | // Note: using /staging/profile_repositories/(profile id) is preferred to reduce the response size, 116 | // but Sonatype API is quite slow (as of Sep 2019) so using a single request was much better. 117 | val response = sonatypClient.stagingRepositoryProfiles 118 | val myProfiles = response.filter(_.profileName == profileName) 119 | if (myProfiles.isEmpty && warnIfMissing) { 120 | warn(s"No staging repository is found. Do publishSigned first.") 121 | } 122 | myProfiles 123 | } 124 | 125 | private def withCache[A](label: String, fileName: String, a: => A)(implicit codec: MessageCodec[A]): A = { 126 | val cachedir = (Vector("sbt", "sonatype") ++ cacheToken).mkString("-") 127 | val cacheRoot = new File(s"target/${cachedir}") 128 | val cacheFile = new File(cacheRoot, fileName) 129 | val value: A = if (cacheFile.exists() && cacheFile.length() > 0) { 130 | Try { 131 | val json = IO.read(cacheFile) 132 | val retval = codec.fromJson(json) 133 | info(s"Using cached ${label}...") 134 | retval 135 | }.getOrElse(a) 136 | } else { 137 | a 138 | } 139 | cacheFile.getParentFile.mkdirs() 140 | IO.write(cacheFile, codec.toJson(value)) 141 | value 142 | } 143 | 144 | def stagingProfiles: Seq[StagingProfile] = { 145 | val profiles = withCache("staging profiles", s"sonatype-profile-${profileName}.json", sonatypClient.stagingProfiles) 146 | profiles.filter(_.name == profileName) 147 | } 148 | 149 | lazy val currentProfile: StagingProfile = { 150 | val profiles = stagingProfiles 151 | if (profiles.isEmpty) { 152 | val error = MISSING_PROFILE(profileName, sonatypClient.repoUri.getHost) 153 | throw SonatypeException(error, error.message) 154 | } 155 | profiles.head 156 | } 157 | 158 | def createStage(description: String = "Requested by sbt-sonatype plugin"): StagingRepositoryProfile = { 159 | sonatypClient.createStage(currentProfile, description) 160 | } 161 | 162 | def closeStage(repo: StagingRepositoryProfile): StagingRepositoryProfile = { 163 | if (repo.isClosed || repo.isReleased) { 164 | info(s"Repository ${repo.repositoryId} is already closed") 165 | repo 166 | } else { 167 | sonatypClient.closeStage(currentProfile, repo) 168 | } 169 | } 170 | 171 | def dropStage(repo: StagingRepositoryProfile): StagingRepositoryProfile = { 172 | sonatypClient.dropStage(currentProfile, repo) 173 | info(s"Dropped successfully: ${repo.repositoryId}") 174 | repo.toDropped 175 | } 176 | 177 | def promoteStage(repo: StagingRepositoryProfile): StagingRepositoryProfile = { 178 | if (repo.isReleased) { 179 | info(s"Repository ${repo.repositoryId} is already released") 180 | } else { 181 | // Post promote(release) request 182 | sonatypClient.promoteStage(currentProfile, repo) 183 | } 184 | dropStage(repo.toReleased) 185 | } 186 | 187 | def stagingRepositoryInfo(repositoryId: String) = { 188 | sonatypClient.stagingRepository(repositoryId) 189 | } 190 | 191 | def closeAndPromote(repo: StagingRepositoryProfile): StagingRepositoryProfile = { 192 | if (repo.isReleased) { 193 | dropStage(repo) 194 | } else { 195 | val closed = closeStage(repo) 196 | promoteStage(closed) 197 | } 198 | } 199 | 200 | def activities: Seq[(StagingRepositoryProfile, Seq[StagingActivity])] = { 201 | for (r <- stagingRepositoryProfiles()) yield r -> sonatypClient.activitiesOf(r) 202 | } 203 | 204 | } 205 | 206 | object SonatypeService { 207 | 208 | /** Switches of a Sonatype command to use 209 | */ 210 | sealed trait CommandType { 211 | def errNotFound: String 212 | } 213 | case object Close extends CommandType { 214 | def errNotFound = "No open repository is found. Run publishSigned first" 215 | } 216 | case object Promote extends CommandType { 217 | def errNotFound = "No closed repository is found. Run publishSigned and close commands" 218 | } 219 | case object Drop extends CommandType { 220 | def errNotFound = "No staging repository is found. Run publishSigned first" 221 | } 222 | case object CloseAndPromote extends CommandType { 223 | def errNotFound = "No staging repository is found. Run publishSigned first" 224 | } 225 | 226 | } 227 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /workflow.md: -------------------------------------------------------------------------------- 1 | ## Single-session workflow example 2 | 3 | ``` 4 | sbt \ 5 | -Dplugin.version=1.1-SNAPSHOT \ 6 | -Dprofile.name="Example Staging Profile" \ 7 | -Dhost=localhost \ 8 | -Drepo=https://localhost:8081/nexus/service/local 9 | 10 | [info] Loading global plugins from /Users/rouquett/.sbt/0.13/plugins 11 | [info] Updating {file:/Users/rouquett/.sbt/0.13/plugins/}global-plugins... 12 | [info] Resolving org.fusesource.jansi#jansi;1.4 ... 13 | [info] Done updating. 14 | ... 15 | [info] Set current project to operations (in build file:.../sbt-sonatype/src/sbt-test/sbt-sonatype/operations/) 16 | > 17 | ``` 18 | 19 | ### 1. Create a new staging repository for a given staging profile 20 | 21 | Use either: 22 | - `sonatypeOpen ""` 23 | - `sonatypeOpen "" ""` 24 | 25 | As a side-effect, two settings will be updated in the SBT state: 26 | - `publishTo` will be set to the URL of the staging repository: `/staging/deployByRepositoryId/` 27 | - `sonatypeStagingRepositoryProfile` will be set to an `StagingRepositoryProfile` object 28 | 29 | ``` 30 | > sonatypeOpen "Example of creating & publishing to a staging repository" 31 | [info] Nexus repository URL: https://localhost:8081/nexus/service/local 32 | [info] sonatypeProfileName = Example Staging Profile 33 | [info] Reading staging profiles... 34 | [info] Creating staging repository in profile: Example Staging Profile 35 | [info] Created successfully: example_staging_profile-1001 36 | [info] Set current project to operations (in build file:.../sbt-sonatype/src/sbt-test/sbt-sonatype/operations/) 37 | > show sonatypeStagingRepositoryProfile 38 | [info] [example_staging_profile-1001] status:open, profile:Example Staging Profile(12aadc320d178f) 39 | > show publishTo 40 | [info] Some(Example Staging Profile: https://localhost:8081/nexus/service/local/staging/deployByRepositoryId/example_staging_profile-1001) 41 | > 42 | ``` 43 | 44 | ### 2. Publish artifacts using the [Targeted Repository](https://github.com/sonatype/nexus-maven-plugins/blob/master/staging/maven-plugin/WORKFLOWS.md#targeted-repository) strategy. 45 | 46 | Since the `publishTo` setting has been updated to point to the newly created staging repository, 47 | the artifacts will be uploaded there. This is equivalent to uploading to a staging repository 48 | using `curl` as described 49 | [here](https://support.sonatype.com/hc/en-us/articles/213465868-Uploading-to-a-Staging-Repository-via-REST-API?page=1#comment_204178478). 50 | 51 | 52 | ``` 53 | > publish 54 | [info] Updating {file:.../sbt-sonatype/src/sbt-test/sbt-sonatype/operations/}operations... 55 | [info] Wrote .../sbt-sonatype/src/sbt-test/sbt-sonatype/operations/target/scala-2.10/operations_2.10-0.1.pom 56 | [info] Resolving org.fusesource.jansi#jansi;1.4 ... 57 | [info] Done updating. 58 | [info] :: delivering :: org.xerial.operations#operations_2.10;0.1 :: 0.1 :: release :: Mon Jan 04 09:38:56 PST 2016 59 | [info] delivering ivy file to .../sbt-sonatype/src/sbt-test/sbt-sonatype/operations/target/scala-2.10/ivy-0.1.xml 60 | [info] published operations_2.10 to https://localhost:8081/nexus/service/local/staging/deployByRepositoryId/example_staging_profile-1001/org/xerial/operations/operations_2.10/0.1/operations_2.10-0.1.pom 61 | [info] published operations_2.10 to https://localhost:8081/nexus/service/local/staging/deployByRepositoryId/example_staging_profile-1001/org/xerial/operations/operations_2.10/0.1/operations_2.10-0.1.jar 62 | [info] published operations_2.10 to https://localhost:8081/nexus/service/local/staging/deployByRepositoryId/example_staging_profile-1001/org/xerial/operations/operations_2.10/0.1/operations_2.10-0.1-sources.jar 63 | [info] published operations_2.10 to https://localhost:8081/nexus/service/local/staging/deployByRepositoryId/example_staging_profile-1001/org/xerial/operations/operations_2.10/0.1/operations_2.10-0.1-javadoc.jar 64 | [success] Total time: 2 s, completed Jan 4, 2016 9:38:58 AM 65 | > 66 | ``` 67 | 68 | ### 3. Close, Drop or Release the staging repository 69 | 70 | ``` 71 | > show sonatypeStagingRepositoryProfile 72 | [info] [example_staging_profile-1001] status:open, profile:Example Staging Profile(12aadc320d178f) 73 | > sonatypeClose 74 | [info] Nexus repository URL: https://localhost:8081/nexus/service/local 75 | [info] sonatypeProfileName = Example Staging Profile 76 | [info] Reading staging repository profiles... 77 | [info] Reading staging profiles... 78 | [info] Closing staging repository [example_staging_profile-1001] status:open, profile:Example Staging Profile(12aadc320d178f) 79 | [info] Activity open started:2016-01-04T09:38:00.167-08:00, stopped:2016-01-04T09:38:00.196-08:00 80 | [info] repositoryCreated: id:example_staging_profile-1001, user:rouquett, ip:137.79.22.153 81 | [info] Activity close started:2016-01-04T09:48:59.092-08:00, stopped:2016-01-04T09:48:59.159-08:00 82 | [info] email: to:Nicolas.F.Rouquette@jpl.nasa.gov 83 | [info] repositoryClosed: id:example_staging_profile-1001 84 | [info] Closed successfully 85 | [info] Set current project to operations (in build file:.../sbt-sonatype/src/sbt-test/sbt-sonatype/operations/) 86 | > show sonatypeStagingRepositoryProfile 87 | [info] [example_staging_profile-1001] status:closed, profile:Example Staging Profile(12aadc320d178f) 88 | > sonatypeList 89 | [info] Nexus repository URL: https://localhost:8081/nexus/service/local 90 | [info] sonatypeProfileName = Example Staging Profile 91 | [info] Reading staging profiles... 92 | [info] Staging profiles (profileName:Example Staging Profile): 93 | [info] StagingProfile(12aadc320d178f,Example Staging Profile,1) 94 | > sonatypeStagingRepositoryProfiles 95 | [info] Nexus repository URL: https://localhost:8081/nexus/service/local 96 | [info] sonatypeProfileName = Example Staging Profile 97 | [info] Reading staging repository profiles... 98 | [info] Staging repository profiles (sonatypeProfileName:Example Staging Profile): 99 | [info] [example_staging_profile-1001] status:closed, profile:Example Staging Profile(12aadc320d178f) 100 | > 101 | 102 | ``` 103 | 104 | ## Multi-session workflow example 105 | 106 | It is possible to perform staging-related operations across separate SBT sessions. 107 | 108 | ### SBT session creating a staging repository 109 | 110 | ``` 111 | sbt \ 112 | -Dplugin.version=1.1-SNAPSHOT \ 113 | -Dprofile.name="Example Staging Profile" \ 114 | -Dhost=localhost \ 115 | -Drepo=https://localhost:8081/nexus/service/local 116 | 117 | [info] Loading global plugins from /Users/rouquett/.sbt/0.13/plugins 118 | [info] Updating {file:/Users/rouquett/.sbt/0.13/plugins/}global-plugins... 119 | [info] Resolving org.fusesource.jansi#jansi;1.4 ... 120 | [info] Done updating. 121 | ... 122 | [info] Set current project to operations (in build file:.../sbt-sonatype/src/sbt-test/sbt-sonatype/operations/) 123 | > sonatypeOpen "dummy" 124 | [info] Nexus repository URL: https://localhost:8081/nexus/service/local 125 | [info] sonatypeProfileName = Example Staging Profile 126 | [info] Reading staging profiles... 127 | [info] Creating staging repository in profile: Example Staging Profile 128 | [info] Created successfully: example_staging_profile-1005 129 | [info] Set current project to operations (in build file:.../sbt-sonatype/src/sbt-test/sbt-sonatype/operations/) 130 | ``` 131 | 132 | ### Another SBT session closing a staging repository 133 | 134 | ``` 135 | sbt \ 136 | -Dplugin.version=1.1-SNAPSHOT \ 137 | -Dprofile.name="Example Staging Profile" \ 138 | -Dhost=localhost \ 139 | -Drepo=https://localhost:8081/nexus/service/local 140 | 141 | [info] Loading global plugins from /Users/rouquett/.sbt/0.13/plugins 142 | [info] Updating {file:/Users/rouquett/.sbt/0.13/plugins/}global-plugins... 143 | [info] Resolving org.fusesource.jansi#jansi;1.4 ... 144 | [info] Done updating. 145 | ... 146 | [info] Set current project to operations (in build file:.../sbt-sonatype/src/sbt-test/sbt-sonatype/operations/) 147 | > sonatypeStagingRepositoryProfiles 148 | [info] Nexus repository URL: https://localhost:8081/nexus/service/local 149 | [info] sonatypeProfileName = Example Staging Profile 150 | [info] Reading staging repository profiles... 151 | [info] Staging repository profiles (sonatypeProfileName:Example Staging Profile): 152 | [info] [example_staging_profile-1005] status:open, profile:Example Staging Profile(12aadc320d178f) 153 | > sonatypeClose example_staging_profile-1005 154 | [info] Nexus repository URL: https://localhost:8081/nexus/service/local 155 | [info] sonatypeProfileName = Example Staging Profile 156 | [info] Reading staging repository profiles... 157 | [info] Reading staging profiles... 158 | [info] Closing staging repository [example_staging_profile-1005] status:open, profile:Example Staging Profile(12aadc320d178f) 159 | [info] Activity open started:2016-01-04T10:55:21.775-08:00, stopped:2016-01-04T10:55:21.820-08:00 160 | [info] repositoryCreated: id:example_staging_profile-1005, user:rouquett, ip:137.79.22.153 161 | [info] Activity close started:2016-01-04T11:05:13.610-08:00, stopped:2016-01-04T11:05:13.659-08:00 162 | [info] email: to:Nicolas.F.Rouquette@jpl.nasa.gov 163 | [info] repositoryClosed: id:example_staging_profile-1005 164 | [info] Closed successfully 165 | [info] Set current project to operations (in build file:.../sbt-sonatype/src/sbt-test/sbt-sonatype/operations/) 166 | > 167 | ``` 168 | 169 | ### Another SBT session promoting a staging repository 170 | 171 | ``` 172 | sbt \ 173 | -Dplugin.version=1.1-SNAPSHOT \ 174 | -Dprofile.name="Example Staging Profile" \ 175 | -Dhost=localhost \ 176 | -Drepo=https://localhost:8081/nexus/service/local 177 | 178 | [info] Loading global plugins from /Users/rouquett/.sbt/0.13/plugins 179 | [info] Updating {file:/Users/rouquett/.sbt/0.13/plugins/}global-plugins... 180 | [info] Resolving org.fusesource.jansi#jansi;1.4 ... 181 | [info] Done updating. 182 | ... 183 | [info] Set current project to operations (in build file:.../sbt-sonatype/src/sbt-test/sbt-sonatype/operations/) 184 | > sonatypeStagingRepositoryProfiles 185 | [info] Nexus repository URL: https://localhost:8081/nexus/service/local 186 | [info] sonatypeProfileName = Example Staging Profile 187 | [info] Reading staging repository profiles... 188 | [info] Staging repository profiles (sonatypeProfileName:Example Staging Profile): 189 | [info] [example_staging_profile-1005] status:closed, profile:Example Staging Profile(12aadc320d178f) 190 | > sonatypeRelease example_staging_profile-1005 191 | [info] Nexus repository URL: https://localhost:8081/nexus/service/local 192 | [info] sonatypeProfileName = Example Staging Profile 193 | [info] Reading staging repository profiles... 194 | [info] Repository example_staging_profile-1005 is already closed 195 | [info] Activity open started:2016-01-04T10:55:21.775-08:00, stopped:2016-01-04T10:55:21.820-08:00 196 | [info] repositoryCreated: id:example_staging_profile-1005, user:rouquett, ip:137.79.22.153 197 | [info] Activity close started:2016-01-04T11:05:13.610-08:00, stopped:2016-01-04T11:05:13.659-08:00 198 | [info] email: to:Nicolas.F.Rouquette@jpl.nasa.gov 199 | [info] repositoryClosed: id:example_staging_profile-1005 200 | [info] Closed successfully 201 | [info] Reading staging profiles... 202 | [info] Promoting staging repository [example_staging_profile-1005] status:closed, profile:Example Staging Profile(12aadc320d178f) 203 | [info] Activity release started:2016-01-04T11:08:20.954-08:00, stopped:2016-01-04T11:08:21.000-08:00 204 | [info] Evaluate: id:nx-internal-ruleset, rule:RepositoryWritePolicy 205 | [info] Evaluate: RepositoryWritePolicy 206 | [info] Passed: RepositoryWritePolicy 207 | [info] Passed: id:nx-internal-ruleset 208 | [info] copyItems: source:example_staging_profile-1005, target:jpl.beta.releases 209 | [info] email: to:Nicolas.F.Rouquette@jpl.nasa.gov 210 | [info] repositoryReleased: id:example_staging_profile-1005, target:jpl.beta.releases 211 | [info] Promoted successfully 212 | [info] Dropping staging repository [example_staging_profile-1005] status:released, profile:Example Staging Profile(12aadc320d178f) 213 | [info] Dropped successfully: example_staging_profile-1005 214 | [info] Set current project to operations (in build file:.../sbt-sonatype/src/sbt-test/sbt-sonatype/operations/) 215 | > 216 | ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | sbt-sonatype plugin 2 | ====== 3 | 4 | A sbt plugin for publishing your project to the Maven central repository through the REST API of Sonatype Nexus. Deploying artifacts to Sonatype repository is a requirement for synchronizing your projects to the [Maven central repository](https://repo1.maven.org/maven2/). __sbt-sonatype__ plugin enables two-step release of your Scala/Java projects. 5 | 6 | * `publishSigned` (with [sbt-pgp plugin](http://www.scala-sbt.org/sbt-pgp/)) 7 | * Create GPG signed artifacts to a local staging repository. 8 | * Make sure adding `publishTo := sonatypePublishToBundle.value` to your build.sbt 9 | * `sonatypeBundleRelease` (New in sbt-sonatype 3.4) 10 | * This command will prepare a new remote staging repository at Sonatype. If there are existing staging repositories that have the same description with `sonatypeSessionName` key, sbt-sonatype will discard them properly. 11 | * Then, it will upload the artifacts in the local staging folder to the remote staging repository. Uploading artifacts as a bundle is much faster than uploading each artifact to Sonatype. For example, thousands of files can be uploaded in several minutes with bundle upload. 12 | * Finally, this command will perform the close and release steps at the Sonatype Nexus repository to meet the Maven central requirements. 13 | 14 | After these steps, your project will be synchronized to the Maven central within ten minutes. No longer need to enter the web interface of 15 | [Sonatype Nexus repository](http://oss.sonatype.org/) to perform these release steps. 16 | 17 | 18 | - [Release notes](ReleaseNotes.md) 19 | - sbt-sonatype is available for sbt 1.x series. 20 | - You can also use sbt-sonatype for [publishing non-sbt projects](README.md#publishing-maven-projects) (e.g., Maven, Gradle, etc.) 21 | - Blog: [Blazingly Fast Release to Sonatype](https://medium.com/@taroleo/sbt-sonatype-f02bdafd78f1) 22 | 23 | ## Prerequisites 24 | 25 | * Create a Sonatype Repository account 26 | * Follow the instruction in the [Central Repository documentation site](http://central.sonatype.org). 27 | * Create a Sonatype account 28 | * Create a GPG key 29 | * Open a JIRA ticket to get a permission for synchronizing your project to the Central Repository (aka Maven Central). 30 | 31 | 32 | * Related articles: 33 | * [Deploying to Sonatype - sbt Documentation](http://www.scala-sbt.org/release/docs/Community/Using-Sonatype.html) 34 | * [Uploading to a Staging Repository via REST API](https://support.sonatype.com/hc/en-us/articles/213465868-Uploading-to-a-Staging-Repository-via-REST-API) 35 | 36 | ## Configurations 37 | 38 | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/org.xerial.sbt/sbt-sonatype/badge.svg)](https://maven-badges.herokuapp.com/maven-central/org.xerial.sbt/sbt-sonatype) 39 | 40 | ### project/plugins.sbt 41 | 42 | Import ***sbt-sonatype*** plugin and [sbt-pgp plugin](http://www.scala-sbt.org/sbt-pgp/) to use `sonatypeBundleRelease` and `publishSigned` 43 | commands: 44 | ```scala 45 | // For sbt 1.x (sbt-sonatype 2.3 or higher) 46 | addSbtPlugin("org.xerial.sbt" % "sbt-sonatype" % "(version)") 47 | addSbtPlugin("com.github.sbt" % "sbt-pgp" % "2.1.2") 48 | 49 | // For sbt 0.13.x (upto sbt-sonatype 2.3) 50 | addSbtPlugin("org.xerial.sbt" % "sbt-sonatype" % "(version)") 51 | addSbtPlugin("com.jsuereth" % "sbt-pgp" % "1.0.0") 52 | ``` 53 | 54 | * If downloading the sbt-sonatype plugin fails, check the repository in the Maven central: . It will be usually synced within 10 minutes. 55 | 56 | ### build.sbt 57 | 58 | #### Hosts other than Sonatype Central 59 | > ⚠️ Legacy Host 60 | > 61 | > 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: 62 | > 63 | > ```sbt 64 | > // For all Sonatype accounts created on or after February 2021 65 | > import xerial.sbt.Sonatype.sonatype01 66 | > 67 | > ThisBuild / sonatypeCredentialHost := sonatype01 68 | > ``` 69 | 70 | #### Sonatype Central Host 71 | 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: 72 | 73 | ```sbt 74 | import xerial.sbt.Sonatype.sonatypeCentralHost 75 | 76 | ThisBuild / sonatypeCredentialHost := sonatypeCentralHost 77 | ``` 78 | Note: if you are publishing an sbt plugin you will also need to configure `sbtPluginPublishLegacyMavenStyle := false` for that project. 79 | Context: sbt publishes plugins with file names that do not conform to the maven specification. Sonatype OSSRH didn't validate this, but Sonatype Central does: `File name 'sbt-my-plugin-0.0.1.jar' is not valid`. 80 | See also: https://github.com/sbt/sbt/issues/3410 81 | 82 | 83 | #### Usage 84 | 85 | 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: 86 | ```scala 87 | publishTo := sonatypePublishToBundle.value 88 | ``` 89 | 90 | 91 | 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. 92 | 93 | 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. 94 | 95 | If necessary, you can tweak several configurations: 96 | ```scala 97 | val sonatypeCentralDeploymentName = 98 | settingKey[String]("Deployment name. Default is .-") 99 | // [Optional] If you need to manage the default Sonatype Central deployment name, change the setting below. 100 | // If publishing multiple modules, ensure that this is set on the module level, rather than on the build level. 101 | sonatypeCentralDeploymentName := s"${organization.value}.${name.value}-${version.value}" 102 | 103 | // [Optional] The local staging folder name: 104 | sonatypeBundleDirectory := (ThisBuild / baseDirectory).value / target.value.getName / "sonatype-staging" / (ThisBuild / version).value 105 | 106 | // [Optional] If you need to manage unique session names by yourself, change this default setting: 107 | sonatypeSessionName := s"[sbt-sonatype] ${name.value} ${version.value}" 108 | 109 | // [Optional] Timeout until giving up sonatype close/promote stages. Default is 60 min. 110 | sonatypeTimeoutMillis := 60 * 60 * 1000 111 | 112 | // [If you cannot use bundle upload] Use this setting when you need to uploads artifacts directly to Sonatype 113 | // With this setting, you cannot use sonatypeBundleXXX commands 114 | publishTo := sonatypePublishTo.value 115 | 116 | // [If necessary] Settings for using custom Nexus repositories: 117 | sonatypeCredentialHost := "s01.oss.sonatype.org" 118 | sonatypeRepository := "https://s01.oss.sonatype.org/service/local" 119 | ``` 120 | 121 | ### $HOME/.sbt/(sbt-version 0.13 or 1.0)/sonatype.sbt 122 | 123 | For the authentication to Sonatype API, you need to set your Sonatype token information (name and password) in the global sbt settings. To protect your password, never include this file within your project. Get the token from https://oss.sonatype.org or https://s01.oss.sonatype.org. 124 | 125 | ```scala 126 | credentials += Credentials("Sonatype Nexus Repository Manager", 127 | "oss.sonatype.org", 128 | "(Sonatype token user name)", 129 | "(Sonatype token password)") 130 | ``` 131 | 132 | ### (project root)/sonatype.sbt 133 | 134 | sbt-sonatype is an auto-plugin, which will automatically configure your build. There are a few settings though that you need to define by yourself: 135 | 136 | * `sonatypeProfileName` 137 | * This is your Sonatype account profile name, e.g. `org.xerial`. If you do not set this value, it will be the same with the `organization` value. 138 | * `pomExtra` 139 | * A fragment of Maven's pom.xml. You must define url, licenses, scm and developers tags in this XML to satisfy [Central Repository sync requirements](http://central.sonatype.org/pages/requirements.html). 140 | 141 | Example settings: 142 | ```scala 143 | // Your profile name of the sonatype account. The default is the same with the organization value 144 | sonatypeProfileName := "(your organization. e.g., org.xerial)" 145 | 146 | // To sync with Maven central, you need to supply the following information: 147 | publishMavenStyle := true 148 | 149 | // Open-source license of your choice 150 | licenses := Seq("APL2" -> url("http://www.apache.org/licenses/LICENSE-2.0.txt")) 151 | 152 | // Where is the source code hosted: GitHub or GitLab? 153 | import xerial.sbt.Sonatype._ 154 | sonatypeProjectHosting := Some(GitHubHosting("username", "projectName", "user@example.com")) 155 | // or 156 | sonatypeProjectHosting := Some(GitLabHosting("username", "projectName", "user@example.com")) 157 | 158 | // or if you want to set these fields manually 159 | homepage := Some(url("https://(your project url)")) 160 | scmInfo := Some( 161 | ScmInfo( 162 | url("https://github.com/(account)/(project)"), 163 | "scm:git@github.com:(account)/(project).git" 164 | ) 165 | ) 166 | developers := List( 167 | Developer(id="(your id)", name="(your name)", email="(your e-mail)", url=url("(your home page)")) 168 | ) 169 | ``` 170 | 171 | ## Publishing Your Artifact 172 | 173 | The basic steps for publishing your artifact to the Central Repository are as follows: 174 | 175 | * `publishSigned` to deploy your artifact to a local staging repository. 176 | * `sonatypeBundleRelease` (since sbt-sonatype 3.4) 177 | * This command is equivalent to running `; sonatypePrepare; sonatypeBundleUpload; sonatypeRelease`. 178 | * Internally `sonatypeRelease` will do `sonatypeClose` and `sonatypePromote` in one step. 179 | * `sonatypeClose` closes your staging repository at Sonatype. This step verifies Maven central sync requirement, GPG-signature, javadoc 180 | and source code presence, pom.xml settings, etc. 181 | * `sonatypePromote` command verifies the closed repository so that it can be synchronized with Maven central. 182 | 183 | Note: If your project version has "SNAPSHOT" suffix, your project will be published to the [snapshot repository](http://oss.sonatype.org/content/repositories/snapshots) of Sonatype, and you cannot use `sonatypeBundleUpload` or `sonatypeRelease` command. 184 | 185 | ## Commands 186 | 187 | ### Multi-Step Commands: 188 | Usually, we only need to run `sonatypeBundleRelease` command in sbt-sonatype: 189 | * __sonatypeBundleRelease__ 190 | * 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. 191 | * If `sonatypeCredentialHost` is set to the Sonatype Central portal, this command will default to the **sonatypeCentralRelease** command. 192 | * You must run `publishSigned` before this command to create a local staging bundle. 193 | * __sonatypeCentralRelease__ 194 | * 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. 195 | * You must run `publishSigned` before this command to create a local staging bundle. 196 | * __sonatypeCentralUpload__ 197 | * 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. 198 | * You must run `publishSigned` before this command to create a local staging bundle. 199 | 200 | ### Individual Step Commands 201 | * __sonatypePrepare__ 202 | * Drop the existing staging repositories (if exist) and create a new staging repository using `sonatypeSessionName` as a unique key. 203 | * This will update `sonatypePublishTo` setting. 204 | * For cross-build projects, make sure running this command only once at the beginning of the release process. 205 | * Usually using sonatypeBundleUpload should be sufficient, but if you need to parallelize artifact uploads, run `sonatypeOpen` before each upload to reuse the already created stging repository. 206 | * __sonatypeBundleUpload__ 207 | * Upload your local staging folder contents to a remote Sonatype repository. 208 | * __sonatypeOpen__ 209 | * This command is necessary only when you need to parallelize `publishSigned` task. For small/medium-size projects, using only `sonatypePrepare` would work. 210 | * This opens the existing staging repository using `sonatypeSessionName` as a unique key. If it doesn't exist, create a new one. It will update`sonatypePublishTo` 211 | * __sonatypeRelease__ (repositoryId)? 212 | * Close (if needed) and promote a staging repository. After this command, the uploaded artifacts will be synchronized to Maven central. 213 | 214 | ### Batch Operations 215 | * __sonatypeDropAll__ (sonatypeProfileName)? 216 | * Drop all staging repositories. 217 | * __sonatypeReleaseAll__ (sonatypeProfileName)? 218 | * Close and promote all staging repositories (Useful for cross-building projects) 219 | 220 | ## Other Commands 221 | * __sonatypeBundleClean__ 222 | * Clean a local bundle folder 223 | * __sonatypeClean__ 224 | * Clean a remote staging repository which has `sonatypeSessionName` key. 225 | * __sonatypeStagingProfiles__ 226 | * Show the list of staging profiles, which include profileName information. 227 | * __sonatypeLog__ 228 | * Show the staging activity logs 229 | * __sonatypeClose__ 230 | * Close the open staging repository (= requirement verification) 231 | * __sonatypePromote__ 232 | * Promote the closed staging repository (= sync to Maven central) 233 | * __sonatypeDrop__ 234 | * Drop an open or closed staging repository 235 | 236 | ## Advanced Build Settings 237 | 238 | ### Sequential Upload Release (Use this for small projects) 239 | 240 | ```scala 241 | > ; publishSigned; sonatypeBundleRelease 242 | ``` 243 | 244 | For cross-building projects, use `+ publishSigned`: 245 | ```scala 246 | > ; + publishSigned; sonatypeBundleRelease 247 | ``` 248 | ### Parallelizing Builds When Sharing A Working Folder 249 | 250 | When you are sharing a working folder, you can parallelize publishSigned step for each module or for each Scala binary version: 251 | 252 | - Run multiple publishSigned tasks in parallel 253 | - Finally, run `sonatypeBundleRelease` 254 | 255 | ### Parallelizing Builds When Not Sharing Any Working Folder 256 | 257 | If you are not sharing any working directory (e.g., Travis CI), to parallelize the release process, you need to publish a bundle for each build because Sonatype API only supports uploading one bundle per a staging repository. 258 | 259 | Here is an example to parallelize your build for each Scala binary version: 260 | - Set `sonatypeSessionName := "[sbt-sonatype] ${name.value}-${scalaBinaryVersion.value}-${version.value}"` to use unique session keys for individual Scala binary versions. 261 | - For each Scala version, run: `sbt ++(Scala version) "; publishSigned; sonatypeBundleRelease"` 262 | 263 | For sbt-sonatype 2.x: 264 | * [Example workflow for creating & publishing to a staging repository](workflow.md) 265 | 266 | ## Using with sbt-release plugin 267 | 268 | To perform publishSigned and sonatypeBundleRelease with [sbt-release](https://github.com/sbt/sbt-release) plugin, define your custom release process as follows: 269 | 270 | ```scala 271 | import ReleaseTransformations._ 272 | 273 | releaseCrossBuild := true // true if you cross-build the project for multiple Scala versions 274 | releaseProcess := Seq[ReleaseStep]( 275 | checkSnapshotDependencies, 276 | inquireVersions, 277 | runClean, 278 | runTest, 279 | setReleaseVersion, 280 | commitReleaseVersion, 281 | tagRelease, 282 | // For non cross-build projects, use releaseStepCommand("publishSigned") 283 | releaseStepCommandAndRemaining("+publishSigned"), 284 | releaseStepCommand("sonatypeBundleRelease"), 285 | setNextVersion, 286 | commitNextVersion, 287 | pushChanges 288 | ) 289 | ``` 290 | 291 | ## Publishing Maven Projects 292 | 293 | If your Maven project (including Gradle, etc.) is already deployed to the staging repository of Sonatype, you can use `sbt sonatypeReleaseAll (sonatypeProfileName)` command 294 | for the synchronization to the Maven central (Since version 0.5.1). 295 | 296 | Prepare the following two files: 297 | 298 | ### $HOME/.sbt/(sbt-version 0.13 or 1.0)/plugins/plugins.sbt 299 | 300 | ```scala 301 | addSbtPlugin("org.xerial.sbt" % "sbt-sonatype" % "(version)") 302 | ``` 303 | 304 | ### $HOME/.sbt/(sbt-version 0.13 or 1.0)/sonatype.sbt 305 | ```scala 306 | credentials += Credentials("Sonatype Nexus Repository Manager", 307 | "oss.sonatype.org", 308 | "(Sonatype user name)", 309 | "(Sonatype password)") 310 | ``` 311 | 312 | Alternatively, the credentials can also be set with the environment variables `SONATYPE_USERNAME` and `SONATYPE_PASSWORD`. 313 | 314 | Then, run `sonatypeReleaseAll` command by specifying your `sonatypeProfileName`. If this is `org.xerial`, run: 315 | ``` 316 | $ sbt "sonatypeReleaseAll org.xerial" 317 | ``` 318 | 319 | 320 | 321 | ## For sbt-sonatype developers 322 | 323 | Releasing sbt-sonatype to Sonatype: 324 | 325 | ```` 326 | ## Add a new git tag 327 | $ git tag v3.9.x 328 | $ ./sbt 329 | > publishSigned 330 | > sonatypeBundleRelease 331 | ``` 332 | -------------------------------------------------------------------------------- /src/main/scala/xerial/sbt/sonatype/SonatypeClient.scala: -------------------------------------------------------------------------------- 1 | package xerial.sbt.sonatype 2 | 3 | import java.io.{File, IOException} 4 | import java.net.URI 5 | import java.util.concurrent.TimeUnit 6 | import org.apache.http.auth.{AuthScope, UsernamePasswordCredentials} 7 | import org.apache.http.impl.client.BasicCredentialsProvider 8 | import org.sonatype.spice.zapper.client.hc4.Hc4ClientBuilder 9 | import org.sonatype.spice.zapper.ParametersBuilder 10 | import sbt.librarymanagement.ivy.Credentials 11 | import scala.annotation.nowarn 12 | import scala.concurrent.duration.Duration 13 | import wvlet.airframe.control.{Control, ResultClass, Retry} 14 | import wvlet.airframe.http.* 15 | import wvlet.airframe.http.client.URLConnectionClientBackend 16 | import wvlet.airframe.http.HttpHeader.MediaType 17 | import wvlet.airframe.http.HttpMessage.Response 18 | import wvlet.log.LogSupport 19 | import xerial.sbt.sonatype.SonatypeException.{BUNDLE_UPLOAD_FAILURE, STAGE_FAILURE, STAGE_IN_PROGRESS} 20 | 21 | /** REST API Client for Sonatype API (nexus-staging) 22 | * https://repository.sonatype.org/nexus-staging-plugin/default/docs/rest.html 23 | */ 24 | class SonatypeClient( 25 | repositoryUrl: String, 26 | cred: Seq[Credentials], 27 | credentialHost: String, 28 | timeoutMillis: Int = 3 * 60 * 60 * 1000 29 | ) extends AutoCloseable 30 | with LogSupport { 31 | 32 | private lazy val sonatypeCredentials = SonatypeCredentials.fromEnvOrError(cred, credentialHost) 33 | 34 | private lazy val base64Credentials = sonatypeCredentials.toBase64 35 | 36 | lazy val repoUri: URI = URI.create(repositoryUrl.stripSuffix("/")) 37 | 38 | private val pathPrefix = repoUri.getPath 39 | 40 | @nowarn("msg=URLConnectionClientBackend") 41 | private[sonatype] val clientConfig = { 42 | var config = Http.client 43 | .withName("sonatype-client") 44 | // Disables the circuit breaker, because Sonatype can be down for a long time https://github.com/xerial/sbt-sonatype/issues/363 45 | .noCircuitBreaker 46 | // Use URLConnectionClient for JDK8 compatibility. Remove this line when using JDK11 or later 47 | .withBackend(URLConnectionClientBackend) 48 | .withJSONEncoding 49 | // Need to set a longer timeout as Sonatype API may not respond quickly 50 | .withReadTimeout(Duration(timeoutMillis, TimeUnit.MILLISECONDS)) 51 | // airframe-http will retry the request several times within this timeout duration. 52 | .withRetryContext { context => 53 | // For individual REST calls, use a normal jittering 54 | context 55 | .withMaxRetry(1000) 56 | .withJitter(initialIntervalMillis = 1500, maxIntervalMillis = 30000) 57 | } 58 | .withRequestFilter { request => 59 | request.withContentTypeJson 60 | .withAccept(MediaType.ApplicationJson) 61 | .withHeader(HttpHeader.Authorization, s"Basic ${base64Credentials}") 62 | } 63 | 64 | val javaVersion = sys.props.getOrElse("java.version", "unknown") 65 | if (javaVersion.startsWith("1.")) { 66 | warn( 67 | s"Disabled http client logging as Java version ${javaVersion} is no longer supported. Please use Java 17 or later." 68 | ) 69 | config = config.noLogging 70 | } else { 71 | // Put the log file under target/sbt-sonatype directory 72 | config = config.withLoggerConfig { 73 | _.withLogFileName("target/sbt-sonatype/sonatype_client_logs.json") 74 | } 75 | } 76 | config 77 | } 78 | 79 | private[sonatype] val httpClient = clientConfig.newSyncClient(repoUri.toString) 80 | 81 | // Create stage is not idempotent, so we just need to wait for a long time without retry 82 | private val httpClientForCreateStage = 83 | clientConfig 84 | .withRetryContext(_.noRetry) 85 | .newSyncClient(repoUri.toString) 86 | 87 | override def close(): Unit = { 88 | Control.closeResources(httpClient, httpClientForCreateStage) 89 | } 90 | 91 | import xerial.sbt.sonatype.SonatypeClient.* 92 | 93 | def stagingRepositoryProfiles: Seq[StagingRepositoryProfile] = { 94 | info("Reading staging repository profiles...") 95 | val result = 96 | httpClient.readAs[Map[String, Seq[StagingRepositoryProfile]]]( 97 | Http.GET(s"${pathPrefix}/staging/profile_repositories") 98 | ) 99 | result.getOrElse("data", Seq.empty) 100 | } 101 | 102 | def stagingRepository(repositoryId: String) = { 103 | info(s"Searching for repository ${repositoryId} ...") 104 | httpClient.readAs[String](Http.GET(s"${pathPrefix}/staging/repository/${repositoryId}")) 105 | } 106 | 107 | def stagingProfiles: Seq[StagingProfile] = { 108 | info("Reading staging profiles...") 109 | val result = httpClient.readAs[StagingProfileResponse](Http.GET(s"${pathPrefix}/staging/profiles")) 110 | result.data 111 | } 112 | 113 | def createStage(profile: StagingProfile, description: String): StagingRepositoryProfile = { 114 | info(s"Creating a staging repository in profile ${profile.name} with a description key: ${description}") 115 | val ret = httpClientForCreateStage.call[Map[String, Map[String, String]], CreateStageResponse]( 116 | Http.POST(s"${pathPrefix}/staging/profiles/${profile.id}/start"), 117 | Map("data" -> Map("description" -> description)) 118 | ) 119 | // Extract created staging repository ids 120 | val repo = StagingRepositoryProfile( 121 | profile.id, 122 | profile.name, 123 | "open", 124 | repositoryId = ret.data.stagedRepositoryId, 125 | ret.data.description 126 | ) 127 | info(s"Created successfully: ${repo.repositoryId}") 128 | repo 129 | } 130 | 131 | private val monitor = new ActivityMonitor() 132 | 133 | /** backoff retry (max 15 sec. / each http request) until the timeout reaches (upto 60 min by default) 134 | */ 135 | private val retryer = { 136 | val maxInterval = 15000 137 | val initInterval = 3000 138 | // init * (multiplier ^ n) = max 139 | // n = log(max / init) / log(multiplier) 140 | val retryCountUntilMaxInterval = (math.log(maxInterval.toDouble / initInterval) / math.log(1.5)).toInt.max(1) 141 | val numRetry = (timeoutMillis / maxInterval).toDouble.ceil.toInt 142 | Retry.withBackOff( 143 | maxRetry = retryCountUntilMaxInterval + numRetry, 144 | initialIntervalMillis = initInterval, 145 | maxIntervalMillis = maxInterval 146 | ) 147 | } 148 | 149 | private def waitForStageCompletion( 150 | taskName: String, 151 | repo: StagingRepositoryProfile, 152 | terminationCond: StagingActivity => Boolean 153 | ): StagingRepositoryProfile = { 154 | retryer 155 | .beforeRetry { ctx => 156 | ctx.lastError match { 157 | case SonatypeException(STAGE_IN_PROGRESS, msg) => 158 | info(f"${msg} Waiting for ${ctx.nextWaitMillis / 1000.0}%.2f sec.") 159 | case _ => 160 | warn( 161 | f"[${ctx.retryCount}/${ctx.maxRetry}] Execution failed: ${ctx.lastError.getMessage}. Retrying in ${ctx.nextWaitMillis / 1000.0}%.2f sec." 162 | ) 163 | } 164 | } 165 | .withResultClassifier[Option[StagingActivity]] { 166 | case Some(activity) if terminationCond(activity) => 167 | info(s"[${taskName}] Finished successfully") 168 | ResultClass.Succeeded 169 | case Some(activity) if (activity.containsError) => 170 | error(s"[${taskName}] Failed") 171 | activity.reportFailure 172 | ResultClass.nonRetryableFailure(SonatypeException(STAGE_FAILURE, s"Failed to ${taskName} the repository.")) 173 | case _ => 174 | ResultClass.retryableFailure(SonatypeException(STAGE_IN_PROGRESS, s"The ${taskName} stage is in progress.")) 175 | } 176 | .run { 177 | val activities = activitiesOf(repo) 178 | monitor.report(activities) 179 | activities.lastOption 180 | } 181 | 182 | repo 183 | } 184 | 185 | def closeStage(currentProfile: StagingProfile, repo: StagingRepositoryProfile): StagingRepositoryProfile = { 186 | info(s"Closing staging repository $repo") 187 | val ret = httpClient.call[Map[String, StageTransitionRequest], Response]( 188 | Http.POST(s"${pathPrefix}/staging/profiles/${repo.profileId}/finish"), 189 | newStageTransitionRequest(currentProfile, repo) 190 | ) 191 | if (ret.statusCode != HttpStatus.Created_201.code) { 192 | throw SonatypeException(STAGE_FAILURE, s"Failed to close the repository. [${ret.status}]: ${ret.contentString}") 193 | } 194 | waitForStageCompletion( 195 | "close", 196 | repo, 197 | terminationCond = { 198 | _.isCloseSucceeded(repo.repositoryId) 199 | } 200 | ).toClosed 201 | } 202 | 203 | def promoteStage(currentProfile: StagingProfile, repo: StagingRepositoryProfile): StagingRepositoryProfile = { 204 | info(s"Promoting staging repository $repo") 205 | val ret = httpClient.call[Map[String, StageTransitionRequest], Response]( 206 | Http.POST(s"${pathPrefix}/staging/profiles/${repo.profileId}/promote"), 207 | newStageTransitionRequest(currentProfile, repo) 208 | ) 209 | if (ret.statusCode != HttpStatus.Created_201.code) { 210 | throw SonatypeException(STAGE_FAILURE, s"Failed to promote the repository. [${ret.status}]: ${ret.contentString}") 211 | } 212 | 213 | waitForStageCompletion("promote", repo, terminationCond = { _.isReleaseSucceeded(repo.repositoryId) }) 214 | } 215 | 216 | def dropStage(currentProfile: StagingProfile, repo: StagingRepositoryProfile): Response = { 217 | info(s"Dropping staging repository $repo") 218 | try { 219 | val ret = httpClient.call[Map[String, StageTransitionRequest], Response]( 220 | Http.POST(s"${pathPrefix}/staging/profiles/${repo.profileId}/drop"), 221 | newStageTransitionRequest(currentProfile, repo) 222 | ) 223 | if (ret.statusCode != HttpStatus.Created_201.code) { 224 | throw SonatypeException(STAGE_FAILURE, s"Failed to drop the repository. [${ret.status}]: ${ret.contentString}") 225 | } 226 | ret 227 | } catch { 228 | case e: HttpClientException if e.status == HttpStatus.NotFound_404 => 229 | warn(s"Staging repository ${repo.profileId} is not found. It might already have been dropped: ${e.getMessage}") 230 | e.response.toHttpResponse 231 | } 232 | } 233 | 234 | private def newStageTransitionRequest( 235 | currentProfile: StagingProfile, 236 | repo: StagingRepositoryProfile 237 | ): Map[String, StageTransitionRequest] = { 238 | Map( 239 | "data" -> StageTransitionRequest( 240 | stagedRepositoryId = repo.repositoryId, 241 | targetRepositoryId = currentProfile.repositoryTargetId, 242 | description = repo.description 243 | ) 244 | ) 245 | } 246 | 247 | def activitiesOf(r: StagingRepositoryProfile): Seq[StagingActivity] = { 248 | debug(s"Checking activity logs of ${r.repositoryId} ...") 249 | httpClient.readAs[Seq[StagingActivity]](Http.GET(s"${pathPrefix}/staging/repository/${r.repositoryId}/activity")) 250 | } 251 | 252 | def uploadBundle(localBundlePath: File, deployPath: String): Unit = { 253 | retryer 254 | .retryOn { 255 | case e: IOException if e.getMessage.contains("502 Bad Gateway") => 256 | // #303 502 can be returned during the bundle upload 257 | Retry.retryableFailure(e) 258 | case e: IOException if e.getMessage.contains("Operation timed out") => 259 | // #223 SSLException 260 | Retry.retryableFailure(e) 261 | case e: IOException if e.getMessage.contains("400 Bad Request") => 262 | Retry.nonRetryableFailure( 263 | SonatypeException( 264 | BUNDLE_UPLOAD_FAILURE, 265 | s"Bundle upload failed. Probably a previously uploaded bundle remains. Run sonatypeClean or sonatypeDropAll first: ${e.getMessage}" 266 | ) 267 | ) 268 | } 269 | .run { 270 | val parameters = ParametersBuilder.defaults().build() 271 | // Adding a trailing slash is necessary to upload a bundle file to a proper location: 272 | val endpoint = s"${repositoryUrl}/${deployPath}/" 273 | val clientBuilder = new Hc4ClientBuilder(parameters, endpoint) 274 | 275 | val credentialProvider = new BasicCredentialsProvider() 276 | val usernamePasswordCredentials = 277 | new UsernamePasswordCredentials(sonatypeCredentials.userName, sonatypeCredentials.password) 278 | 279 | credentialProvider.setCredentials(AuthScope.ANY, usernamePasswordCredentials) 280 | 281 | clientBuilder.withPreemptiveRealm(credentialProvider) 282 | 283 | if (!localBundlePath.isDirectory) { 284 | info( 285 | s"Directory $localBundlePath does not exist. Hint: Make sure you don't have any uncommitted files (i.e. generated via plugins like scalafmt)" 286 | ) 287 | } 288 | 289 | import org.sonatype.spice.zapper.fs.DirectoryIOSource 290 | val deployables = new DirectoryIOSource(localBundlePath) 291 | 292 | val client = clientBuilder.build() 293 | try { 294 | info(s"Uploading bundle ${localBundlePath} to ${endpoint}") 295 | client.upload(deployables) 296 | info(s"Finished bundle upload: ${localBundlePath}") 297 | } finally { 298 | client.close() 299 | } 300 | } 301 | } 302 | 303 | } 304 | 305 | object SonatypeClient extends LogSupport { 306 | 307 | case class StagingProfileResponse(data: Seq[StagingProfile] = Seq.empty) 308 | 309 | /** Staging profile is the information associated to a Sonatype account. 310 | */ 311 | case class StagingProfile(id: String, name: String, repositoryTargetId: String) 312 | 313 | /** Staging repository profile has an id of deployed artifact and the current staging state. 314 | */ 315 | case class StagingRepositoryProfile( 316 | profileId: String, 317 | profileName: String, 318 | `type`: String, 319 | repositoryId: String, 320 | description: String 321 | ) { 322 | def stagingType: String = `type` 323 | 324 | override def toString = 325 | s"[$repositoryId] status:$stagingType, profile:$profileName($profileId) description: $description" 326 | def isOpen = stagingType == "open" 327 | def isClosed = stagingType == "closed" 328 | def isReleased = stagingType == "released" 329 | 330 | def toClosed = copy(`type` = "closed") 331 | def toDropped = copy(`type` = "dropped") 332 | def toReleased = copy(`type` = "released") 333 | 334 | def deployPath: String = s"staging/deployByRepositoryId/${repositoryId}" 335 | } 336 | 337 | case class CreateStageResponse( 338 | data: StagingRepositoryRef 339 | ) 340 | case class StagingRepositoryRef( 341 | stagedRepositoryId: String, 342 | description: String 343 | ) 344 | 345 | case class StageTransitionRequest( 346 | stagedRepositoryId: String, 347 | targetRepositoryId: String, 348 | description: String 349 | ) 350 | 351 | case class Prop(name: String, value: String) 352 | 353 | /** ActivityEvent is an evaluation result (e.g., checksum, signature check, etc.) of a rule defined in a 354 | * StagingActivity ruleset 355 | * @param timestamp 356 | * @param name 357 | * @param severity 358 | * @param properties 359 | */ 360 | case class ActivityEvent(timestamp: String, name: String, severity: Int, properties: Seq[Prop]) { 361 | lazy val map = properties.map(x => x.name -> x.value).toMap 362 | def ruleType: String = map.getOrElse("typeId", "other") 363 | def isFailure = name == "ruleFailed" 364 | 365 | override def toString = { 366 | s"-event -- timestamp:$timestamp, name:$name, severity:$severity, ${properties.map(p => s"${p.name}:${p.value}").mkString(", ")}" 367 | } 368 | 369 | def showProgress(useErrorLog: Boolean = false): Unit = { 370 | val props = { 371 | val front = 372 | if (map.contains("typeId")) 373 | Seq(map("typeId")) 374 | else 375 | Seq.empty 376 | front ++ map.filter(_._1 != "typeId").map(p => s"${p._1}:${p._2}") 377 | } 378 | val messageLine = props.mkString(", ") 379 | val name_s = name.replaceAll("rule(s)?", "") 380 | val message = f"$name_s%10s: $messageLine" 381 | if (useErrorLog) 382 | logger.error(message) 383 | else 384 | logger.info(message) 385 | } 386 | } 387 | 388 | class ActivityMonitor { 389 | var reportedActivities = Set.empty[String] 390 | var reportedEvents = Set.empty[ActivityEvent] 391 | 392 | def report(stagingActivities: Seq[StagingActivity]) = { 393 | for (sa <- stagingActivities) { 394 | if (!reportedActivities.contains(sa.started)) { 395 | logger.info(sa.activityLog) 396 | reportedActivities += sa.started 397 | } 398 | for (ae <- sa.events if !reportedEvents.contains(ae)) { 399 | ae.showProgress(useErrorLog = false) 400 | reportedEvents += ae 401 | } 402 | } 403 | } 404 | } 405 | 406 | /** Staging activity is an action to the staged repository 407 | * @param name 408 | * activity name, e.g. open, close, promote, etc. 409 | * @param started 410 | * @param stopped 411 | * @param events 412 | */ 413 | case class StagingActivity(name: String, started: String, stopped: String, events: Seq[ActivityEvent]) { 414 | override def toString = { 415 | val b = Seq.newBuilder[String] 416 | b += activityLog 417 | for (e <- events) 418 | b += s" ${e.toString}" 419 | b.result.mkString("\n") 420 | } 421 | 422 | def activityLog = { 423 | val b = Seq.newBuilder[String] 424 | b += s"Activity name:${name}" 425 | b += s"started:${started}" 426 | if (stopped.nonEmpty) { 427 | b += s"stopped:${stopped}" 428 | } 429 | b.result().mkString(", ") 430 | } 431 | 432 | def showProgress: Unit = { 433 | logger.info(activityLog) 434 | val hasError = containsError 435 | for (e <- suppressEvaluateLog) { 436 | e.showProgress(hasError) 437 | } 438 | } 439 | 440 | def suppressEvaluateLog = { 441 | val in = events.toIndexedSeq 442 | var cursor = 0 443 | val b = Seq.newBuilder[ActivityEvent] 444 | while (cursor < in.size) { 445 | val current = in(cursor) 446 | if (cursor < in.size - 1) { 447 | val next = in(cursor + 1) 448 | if (current.name == "ruleEvaluate" && current.ruleType == next.ruleType) { 449 | // skip 450 | } else { 451 | b += current 452 | } 453 | } 454 | cursor += 1 455 | } 456 | b.result 457 | } 458 | 459 | def containsError = events.exists(_.severity != 0) 460 | 461 | def failureReport = suppressEvaluateLog.filter(_.isFailure) 462 | 463 | def reportFailure: Unit = { 464 | logger.error(activityLog) 465 | for (e <- failureReport) { 466 | e.showProgress(useErrorLog = true) 467 | } 468 | } 469 | 470 | def isReleaseSucceeded(repositoryId: String): Boolean = { 471 | events 472 | .find(_.name == "repositoryReleased") 473 | .exists(_.map.getOrElse("id", "") == repositoryId) 474 | } 475 | 476 | def isCloseSucceeded(repositoryId: String): Boolean = { 477 | events 478 | .find(_.name == "repositoryClosed") 479 | .exists(_.map.getOrElse("id", "") == repositoryId) 480 | } 481 | } 482 | } 483 | -------------------------------------------------------------------------------- /src/main/scala/xerial/sbt/Sonatype.scala: -------------------------------------------------------------------------------- 1 | //-------------------------------------- 2 | // 3 | // Sonatype.scala 4 | // Since: 2014/01/05 5 | // 6 | //-------------------------------------- 7 | 8 | package xerial.sbt 9 | 10 | import com.lumidion.sonatype.central.client.core.{DeploymentName, PublishingType} 11 | import sbt.* 12 | import sbt.librarymanagement.MavenRepository 13 | import sbt.Keys.* 14 | import scala.concurrent.{Await, ExecutionContext, Future} 15 | import scala.concurrent.duration.Duration 16 | import scala.util.hashing.MurmurHash3 17 | import wvlet.log.{LogLevel, LogSupport} 18 | import xerial.sbt.sonatype.* 19 | import xerial.sbt.sonatype.utils.Extensions.* 20 | import xerial.sbt.sonatype.SonatypeClient.StagingRepositoryProfile 21 | import xerial.sbt.sonatype.SonatypeException.GENERIC_ERROR 22 | import xerial.sbt.sonatype.SonatypeService.* 23 | 24 | /** Plugin for automating release processes at Sonatype Nexus 25 | */ 26 | object Sonatype extends AutoPlugin with LogSupport { 27 | wvlet.log.Logger.init 28 | 29 | trait SonatypeKeys { 30 | val sonatypeRepository = settingKey[String]("Sonatype repository URL: e.g. https://oss.sonatype.org/service/local") 31 | val sonatypeProfileName = settingKey[String]("Profile name at Sonatype: e.g. org.xerial") 32 | val sonatypeCredentialHost = settingKey[String]("Credential host. Default is oss.sonatype.org") 33 | val sonatypeCentralDeploymentName = 34 | settingKey[String]("Deployment name. Default is .-") 35 | val sonatypeDefaultResolver = settingKey[Resolver]("Default Sonatype Resolver") 36 | val sonatypePublishTo = settingKey[Option[Resolver]]("Default Sonatype publishTo target") 37 | val sonatypePublishToBundle = settingKey[Option[Resolver]]("Default Sonatype publishTo target") 38 | val sonatypeTargetRepositoryProfile = settingKey[StagingRepositoryProfile]("Stating repository profile") 39 | val sonatypeProjectHosting = 40 | settingKey[Option[ProjectHosting]]("Shortcut to fill in required Maven Central information") 41 | val sonatypeSessionName = settingKey[String]("Used for identifying a sonatype staging repository") 42 | val sonatypeTimeoutMillis = settingKey[Int]("milliseconds before giving up Sonatype API requests") 43 | val sonatypeBundleClean = taskKey[Unit]("Clean up the local bundle folder") 44 | val sonatypeBundleDirectory = settingKey[File]("Directory to create a bundle") 45 | val sonatypeBundleRelease = taskKey[String]("Release a bundle to Sonatype") 46 | val sonatypeLogLevel = settingKey[String]("log level: trace, debug, info warn, error") 47 | val sonatypeSnapshotResolver = settingKey[Resolver]("Sonatype snapshot resolver") 48 | val sonatypeStagingResolver = settingKey[Resolver]("Sonatype staging resolver") 49 | } 50 | 51 | object SonatypeKeys extends SonatypeKeys {} 52 | 53 | object autoImport extends SonatypeKeys {} 54 | 55 | override def trigger = allRequirements 56 | override def projectSettings = sonatypeSettings 57 | override def buildSettings = sonatypeBuildSettings 58 | 59 | import autoImport.* 60 | import complete.DefaultParsers.* 61 | 62 | private implicit val ec: ExecutionContext = ExecutionContext.global 63 | 64 | val sonatypeLegacy = "oss.sonatype.org" 65 | val sonatype01 = "s01.oss.sonatype.org" 66 | val sonatypeCentralHost = SonatypeCentralClient.host 67 | val knownOssHosts = Seq(sonatypeLegacy, sonatype01) 68 | 69 | lazy val sonatypeBuildSettings = Seq[Def.Setting[_]]( 70 | sonatypeCredentialHost := sonatypeLegacy 71 | ) 72 | lazy val sonatypeSettings = Seq[Def.Setting[_]]( 73 | sonatypeProfileName := organization.value, 74 | sonatypeRepository := s"https://${sonatypeCredentialHost.value}/service/local", 75 | sonatypeProjectHosting := None, 76 | publishMavenStyle := true, 77 | pomIncludeRepository := { _ => 78 | false 79 | }, 80 | credentials ++= { 81 | val alreadyContainsSonatypeCredentials: Boolean = credentials.value.exists { 82 | case d: DirectCredentials => d.host == sonatypeCredentialHost.value 83 | case _ => false 84 | } 85 | if (!alreadyContainsSonatypeCredentials) { 86 | val env = sys.env.get(_) 87 | (for { 88 | username <- env("SONATYPE_USERNAME") 89 | password <- env("SONATYPE_PASSWORD") 90 | } yield Credentials( 91 | "Sonatype Nexus Repository Manager", 92 | sonatypeCredentialHost.value, 93 | username, 94 | password 95 | )).toSeq 96 | } else Seq.empty 97 | }, 98 | homepage := homepage.value.orElse(sonatypeProjectHosting.value.map(h => url(h.homepage))), 99 | scmInfo := sonatypeProjectHosting.value.map(_.scmInfo).orElse(scmInfo.value), 100 | developers := { 101 | val derived = sonatypeProjectHosting.value.map(h => List(h.developer)).getOrElse(List.empty) 102 | if (developers.value.isEmpty) derived 103 | else developers.value 104 | }, 105 | sonatypeCentralDeploymentName := DeploymentName.fromArtifact(organization.value, name.value, version.value).unapply, 106 | sonatypePublishTo := { 107 | if (sonatypeCredentialHost.value == SonatypeCentralClient.host && version.value.endsWith("-SNAPSHOT")) { 108 | None 109 | } else Some(sonatypeDefaultResolver.value) 110 | }, 111 | sonatypeBundleDirectory := { 112 | (ThisBuild / baseDirectory).value / "target" / "sonatype-staging" / s"${(ThisBuild / version).value}" 113 | }, 114 | sonatypeBundleClean := { 115 | IO.delete(sonatypeBundleDirectory.value) 116 | }, 117 | sonatypePublishToBundle := { 118 | if (version.value.endsWith("-SNAPSHOT")) { 119 | if (sonatypeCredentialHost.value == sonatypeCentralHost) { 120 | None 121 | } else { 122 | // Sonatype snapshot repositories have no support for bundle upload, 123 | // so use direct publishing to the snapshot repo. 124 | Some(sonatypeSnapshotResolver.value) 125 | } 126 | } else { 127 | Some(Resolver.file("sonatype-local-bundle", sonatypeBundleDirectory.value)) 128 | } 129 | }, 130 | sonatypeSnapshotResolver := { 131 | MavenRepository( 132 | s"${sonatypeCredentialHost.value.replace('.', '-')}-snapshots", 133 | s"https://${sonatypeCredentialHost.value}/content/repositories/snapshots" 134 | ) 135 | }, 136 | sonatypeStagingResolver := { 137 | MavenRepository( 138 | s"${sonatypeCredentialHost.value.replace('.', '-')}-staging", 139 | s"https://${sonatypeCredentialHost.value}/service/local/staging/deploy/maven2" 140 | ) 141 | }, 142 | sonatypeDefaultResolver := { 143 | if (sonatypeCredentialHost.value == SonatypeCentralClient.host) { 144 | Resolver.url(s"https://${sonatypeCredentialHost.value}") 145 | } else { 146 | val profileM = sonatypeTargetRepositoryProfile.?.value 147 | val repository = sonatypeRepository.value 148 | val staged = profileM.map { stagingRepoProfile => 149 | s"${sonatypeCredentialHost.value.replace('.', '-')}-releases" at s"${repository}/${stagingRepoProfile.deployPath}" 150 | } 151 | staged.getOrElse(if (version.value.endsWith("-SNAPSHOT")) { 152 | sonatypeSnapshotResolver.value 153 | } else { 154 | sonatypeStagingResolver.value 155 | }) 156 | } 157 | }, 158 | sonatypeTimeoutMillis := 60 * 60 * 1000, // 60 minutes 159 | sonatypeSessionName := s"[sbt-sonatype] ${name.value} ${version.value}", 160 | sonatypeLogLevel := "info", 161 | commands ++= Seq( 162 | sonatypeCentralRelease, 163 | sonatypeCentralUpload, 164 | sonatypeBundleRelease, 165 | sonatypeBundleUpload, 166 | sonatypePrepare, 167 | sonatypeOpen, 168 | sonatypeClose, 169 | sonatypePromote, 170 | sonatypeDrop, 171 | sonatypeRelease, 172 | sonatypeClean, 173 | sonatypeReleaseAll, 174 | sonatypeDropAll, 175 | sonatypeLog, 176 | sonatypeStagingRepositoryProfiles, 177 | sonatypeStagingProfiles 178 | ) 179 | ) 180 | 181 | private def prepare(state: State, rest: SonatypeService): StagingRepositoryProfile = { 182 | val extracted = Project.extract(state) 183 | val descriptionKey = extracted.get(sonatypeSessionName) 184 | state.log.info(s"Preparing a new staging repository for ${descriptionKey}") 185 | // Drop a previous staging repository if exists 186 | val dropTask = Future.apply(rest.dropIfExistsByKey(descriptionKey)) 187 | // Create a new one 188 | val createTask = Future.apply(rest.createStage(descriptionKey)) 189 | // Run two tasks in parallel 190 | val merged = dropTask.zip(createTask) 191 | val (droppedRepo, createdRepo) = Await.result(merged, Duration.Inf) 192 | createdRepo 193 | } 194 | private def sonatypeCentralDeployCommand(state: State, publishingType: PublishingType): State = { 195 | val extracted = Project.extract(state) 196 | val bundlePath = extracted.get(sonatypeBundleDirectory) 197 | val credentialHost = extracted.get(sonatypeCredentialHost) 198 | val isVersionSnapshot = extracted.get(version).endsWith("-SNAPSHOT") 199 | 200 | if (credentialHost == SonatypeCentralClient.host) { 201 | if (isVersionSnapshot) { 202 | error( 203 | "Version cannot be a snapshot version when deploying to sonatype central. Please ensure that the version is publishable and try again." 204 | ) 205 | state.fail 206 | } else { 207 | val deploymentName = DeploymentName(extracted.get(sonatypeCentralDeploymentName)) 208 | withSonatypeCentralService(state) { service => 209 | service 210 | .uploadBundle(bundlePath, deploymentName, publishingType) 211 | .map(_ => state) 212 | } 213 | } 214 | } else { 215 | error( 216 | s"sonatypeCredentialHost key needs to be set to $sonatypeCentralHost in order to release to sonatype central. Please adjust the key and try again." 217 | ) 218 | state.fail 219 | } 220 | } 221 | 222 | private val sonatypeCentralUpload = newCommand( 223 | "sonatypeCentralUpload", 224 | "Upload a bundle in sonatypeBundleDirectory to Sonatype Central that can be released after manual approval in Sonatype Central" 225 | )(sonatypeCentralDeployCommand(_, PublishingType.USER_MANAGED)) 226 | 227 | private val sonatypeCentralRelease = newCommand( 228 | "sonatypeCentralRelease", 229 | "Upload a bundle in sonatypeBundleDirectory to Sonatype Central that will be released automatically to Maven Central" 230 | )(sonatypeCentralDeployCommand(_, PublishingType.AUTOMATIC)) 231 | 232 | private val sonatypeBundleRelease = 233 | newCommand("sonatypeBundleRelease", "Upload a bundle in sonatypeBundleDirectory and release it at Sonatype") { 234 | (state: State) => 235 | val extracted = Project.extract(state) 236 | val credentialHost = extracted.get(sonatypeCredentialHost) 237 | 238 | if (credentialHost == SonatypeCentralClient.host) { 239 | sonatypeCentralDeployCommand(state, PublishingType.AUTOMATIC) 240 | } else { 241 | withSonatypeService(state) { rest => 242 | val repo = prepare(state, rest) 243 | val bundlePath = extracted.get(sonatypeBundleDirectory) 244 | rest.uploadBundle(bundlePath, repo.deployPath) 245 | rest.closeAndPromote(repo) 246 | updatePublishSettings(state, repo) 247 | } 248 | } 249 | } 250 | 251 | private val sonatypeBundleUpload = newCommand("sonatypeBundleUpload", "Upload a bundle in sonatypeBundleDirectory") { 252 | (state: State) => 253 | val extracted = Project.extract(state) 254 | val bundlePath = extracted.get(sonatypeBundleDirectory) 255 | withSonatypeService(state) { rest => 256 | val repo = extracted.getOpt(sonatypeTargetRepositoryProfile).getOrElse { 257 | val descriptionKey = extracted.get(sonatypeSessionName) 258 | rest.openOrCreateByKey(descriptionKey) 259 | } 260 | rest.uploadBundle(bundlePath, repo.deployPath) 261 | updatePublishSettings(state, repo) 262 | } 263 | } 264 | 265 | private val sonatypePrepare = newCommand( 266 | "sonatypePrepare", 267 | "Clean (if exists) and create a staging repository for releasing the current version, then update publishTo" 268 | ) { (state: State) => 269 | withSonatypeService(state) { rest => 270 | val repo = prepare(state, rest) 271 | updatePublishSettings(state, repo) 272 | } 273 | } 274 | 275 | private val sonatypeOpen = newCommand( 276 | "sonatypeOpen", 277 | "Open (or create if not exists) to a staging repository for the current version, then update publishTo" 278 | ) { (state: State) => 279 | withSonatypeService(state) { rest => 280 | // Re-open or create a staging repository 281 | val descriptionKey = Project.extract(state).get(sonatypeSessionName) 282 | val repo = rest.openOrCreateByKey(descriptionKey) 283 | updatePublishSettings(state, repo) 284 | } 285 | } 286 | 287 | private def updatePublishSettings(state: State, repo: StagingRepositoryProfile): State = { 288 | val extracted = Project.extract(state) 289 | // accumulate changes for settings for current project and all aggregates 290 | state.log.info(s"Updating sonatypePublishTo settings...") 291 | val newSettings: Seq[Def.Setting[_]] = extracted.currentProject.referenced.flatMap { ref => 292 | Seq( 293 | ref / sonatypeTargetRepositoryProfile := repo 294 | ) 295 | } ++ Seq( 296 | sonatypeTargetRepositoryProfile := repo 297 | ) 298 | 299 | val next = extracted.appendWithoutSession(newSettings, state) 300 | next 301 | } 302 | 303 | private val sonatypeClose = commandWithRepositoryId("sonatypeClose", "") { (state: State, arg: Option[String]) => 304 | val extracted = Project.extract(state) 305 | val repoID = arg.orElse(extracted.getOpt(sonatypeTargetRepositoryProfile).map(_.repositoryId)) 306 | withSonatypeService(state) { rest => 307 | val repo1 = rest.findTargetRepository(Close, repoID) 308 | val repo2 = rest.closeStage(repo1) 309 | extracted.appendWithoutSession(Seq(sonatypeTargetRepositoryProfile := repo2), state) 310 | } 311 | } 312 | 313 | private val sonatypePromote = commandWithRepositoryId("sonatypePromote", "Promote a staging repository") { 314 | (state: State, arg: Option[String]) => 315 | val extracted = Project.extract(state) 316 | val repoID = arg.orElse(extracted.getOpt(sonatypeTargetRepositoryProfile).map(_.repositoryId)) 317 | withSonatypeService(state) { rest => 318 | val repo1 = rest.findTargetRepository(Promote, repoID) 319 | val repo2 = rest.promoteStage(repo1) 320 | extracted.appendWithoutSession(Seq(sonatypeTargetRepositoryProfile := repo2), state) 321 | } 322 | } 323 | 324 | private val sonatypeDrop = commandWithRepositoryId("sonatypeDrop", "Drop a staging repository") { 325 | (state: State, arg: Option[String]) => 326 | val extracted = Project.extract(state) 327 | val repoID = arg.orElse(extracted.getOpt(sonatypeTargetRepositoryProfile).map(_.repositoryId)) 328 | withSonatypeService(state) { rest => 329 | val repo1 = rest.findTargetRepository(Drop, repoID) 330 | val repo2 = rest.dropStage(repo1) 331 | extracted.appendWithoutSession(Seq(sonatypeTargetRepositoryProfile := repo2), state) 332 | } 333 | } 334 | 335 | private val sonatypeRelease = 336 | commandWithRepositoryId("sonatypeRelease", "Publish with sonatypeClose and sonatypePromote") { 337 | (state: State, arg: Option[String]) => 338 | val extracted = Project.extract(state) 339 | val repoID = arg.orElse(extracted.getOpt(sonatypeTargetRepositoryProfile).map(_.repositoryId)) 340 | withSonatypeService(state) { rest => 341 | val repo1 = rest.findTargetRepository(CloseAndPromote, repoID) 342 | val repo2 = rest.closeAndPromote(repo1) 343 | extracted.appendWithoutSession(Seq(sonatypeTargetRepositoryProfile := repo2), state) 344 | } 345 | } 346 | 347 | private val sonatypeClean = 348 | newCommand("sonatypeClean", "Clean a staging repository for the current version if it exists") { (state: State) => 349 | val extracted = Project.extract(state) 350 | withSonatypeService(state) { rest => 351 | val descriptionKey = extracted.get(sonatypeSessionName) 352 | rest.dropIfExistsByKey(descriptionKey) 353 | state 354 | } 355 | } 356 | 357 | private val sonatypeReleaseAll = 358 | commandWithRepositoryId("sonatypeReleaseAll", "Publish all staging repositories to Maven central") { 359 | (state: State, arg: Option[String]) => 360 | withSonatypeService(state, arg) { rest => 361 | val tasks = rest.stagingRepositoryProfiles().map { repo => 362 | Future.apply(rest.closeAndPromote(repo)) 363 | } 364 | val merged = Future.sequence(tasks) 365 | Await.result(merged, Duration.Inf) 366 | state 367 | } 368 | } 369 | 370 | private val sonatypeDropAll = commandWithRepositoryId("sonatypeDropAll", "Drop all staging repositories") { 371 | (state: State, arg: Option[String]) => 372 | withSonatypeService(state, arg) { rest => 373 | val dropTasks = rest.stagingRepositoryProfiles().map { repo => 374 | Future.apply(rest.dropStage(repo)) 375 | } 376 | val merged = Future.sequence(dropTasks) 377 | Await.result(merged, Duration.Inf) 378 | state 379 | } 380 | } 381 | 382 | private val sonatypeLog = newCommand("sonatypeLog", "Show staging activity logs at Sonatype") { (state: State) => 383 | withSonatypeService(state) { rest => 384 | val alist = rest.activities 385 | if (alist.isEmpty) 386 | warn("No staging log is found") 387 | for ((repo, activities) <- alist) { 388 | info(s"Staging activities of $repo:") 389 | for (a <- activities) { 390 | a.showProgress 391 | } 392 | } 393 | state 394 | } 395 | } 396 | 397 | private val sonatypeStagingRepositoryProfiles = 398 | newCommand("sonatypeStagingRepositoryProfiles", "Show the list of staging repository profiles") { (state: State) => 399 | withSonatypeService(state) { rest => 400 | val repos = rest.stagingRepositoryProfiles() 401 | if (repos.isEmpty) 402 | warn(s"No staging repository is found for ${rest.profileName}") 403 | else { 404 | info(s"Staging repository profiles (sonatypeProfileName:${rest.profileName}):") 405 | info(repos.mkString("\n")) 406 | } 407 | state 408 | } 409 | } 410 | 411 | private val sonatypeStagingProfiles = newCommand("sonatypeStagingProfiles", "Show the list of staging profiles") { 412 | (state: State) => 413 | withSonatypeService(state) { rest => 414 | val profiles = rest.stagingProfiles 415 | if (profiles.isEmpty) 416 | warn(s"No staging profile is found for ${rest.profileName}") 417 | else { 418 | info(s"Staging profiles (sonatypeProfileName:${rest.profileName}):") 419 | info(profiles.mkString("\n")) 420 | } 421 | state 422 | } 423 | } 424 | 425 | case class ProjectHosting( 426 | domain: String, 427 | user: String, 428 | fullName: Option[String], 429 | email: String, 430 | repository: String 431 | ) { 432 | def homepage = s"https://$domain/$user/$repository" 433 | def scmUrl = s"git@$domain:$user/$repository.git" 434 | def scmInfo = ScmInfo(url(homepage), scmUrl) 435 | def developer = Developer(user, fullName.getOrElse(user), email, url(s"https://$domain/$user")) 436 | } 437 | 438 | object GitHubHosting { 439 | private val domain = "github.com" 440 | def apply(user: String, repository: String, email: String) = ProjectHosting(domain, user, None, email, repository) 441 | def apply(user: String, repository: String, fullName: String, email: String) = 442 | ProjectHosting(domain, user, Some(fullName), email, repository) 443 | } 444 | 445 | object GitLabHosting { 446 | private val domain = "gitlab.com" 447 | def apply(user: String, repository: String, email: String) = ProjectHosting(domain, user, None, email, repository) 448 | def apply(user: String, repository: String, fullName: String, email: String) = 449 | ProjectHosting(domain, user, Some(fullName), email, repository) 450 | } 451 | 452 | // aliases 453 | @deprecated("Use GitHubHosting (capital H) instead", "2.2") 454 | val GithubHosting = GitHubHosting 455 | @deprecated("Use GitLabHosting (capital L) instead", "2.2") 456 | val GitlabHosting = GitLabHosting 457 | 458 | private val repositoryIdParser: complete.Parser[Option[String]] = 459 | (Space ~> token(StringBasic, "(sonatype staging repository id)")).?.!!!( 460 | "invalid input. please input a repository id" 461 | ) 462 | 463 | private def getCredentials(extracted: Extracted, state: State) = { 464 | val (_, credential) = extracted.runTask(credentials, state) 465 | credential 466 | } 467 | 468 | private def withSonatypeCentralService( 469 | state: State 470 | )(func: SonatypeCentralService => Either[SonatypeException, State]): State = { 471 | val extracted = Project.extract(state) 472 | val logLevel = LogLevel(extracted.get(sonatypeLogLevel)) 473 | wvlet.log.Logger.setDefaultLogLevel(logLevel) 474 | 475 | val credentials = getCredentials(extracted, state) 476 | 477 | val eitherOp = for { 478 | client <- SonatypeCentralClient.fromCredentials( 479 | credentials, 480 | readTimeoutMillis = extracted.get(sonatypeTimeoutMillis).toLong 481 | ) 482 | service = new SonatypeCentralService(client) 483 | res <- 484 | try { 485 | func(service) 486 | } catch { 487 | case e: Throwable => Left(new SonatypeException(GENERIC_ERROR, e.getMessage)) 488 | } finally { 489 | client.close() 490 | } 491 | } yield res 492 | 493 | try { 494 | eitherOp.getOrError 495 | } catch { 496 | case e: SonatypeException => 497 | error(e.toString) 498 | state.fail 499 | } 500 | } 501 | 502 | private def withSonatypeService(state: State, profileName: Option[String] = None)( 503 | body: SonatypeService => State 504 | ): State = { 505 | val extracted = Project.extract(state) 506 | val logLevel = LogLevel(extracted.get(sonatypeLogLevel)) 507 | wvlet.log.Logger.setDefaultLogLevel(logLevel) 508 | 509 | val repositoryUrl = extracted.get(sonatypeRepository) 510 | val creds = getCredentials(extracted, state) 511 | val credentialHost = extracted.get(sonatypeCredentialHost) 512 | 513 | val hashsum: String = { 514 | val input = Vector(repositoryUrl, creds.toString(), credentialHost).mkString("-") 515 | MurmurHash3.stringHash(input).abs.toString() 516 | } 517 | 518 | val sonatypeClient = new SonatypeClient( 519 | repositoryUrl = repositoryUrl, 520 | cred = creds, 521 | credentialHost = credentialHost, 522 | timeoutMillis = extracted.get(sonatypeTimeoutMillis) 523 | ) 524 | val service = new SonatypeService( 525 | sonatypeClient, 526 | profileName.getOrElse(extracted.get(sonatypeProfileName)), 527 | Some(hashsum) 528 | ) 529 | try { 530 | body(service) 531 | } catch { 532 | case e: SonatypeException => 533 | error(e.toString) 534 | state.fail 535 | case e: Throwable => 536 | error(e) 537 | state.fail 538 | } finally { 539 | service.close() 540 | } 541 | } 542 | 543 | private def newCommand(name: String, briefHelp: String)(body: State => State) = { 544 | Command.command(name, briefHelp, briefHelp)(body) 545 | } 546 | 547 | private def commandWithRepositoryId(name: String, briefHelp: String) = 548 | Command(name, (name, briefHelp), briefHelp)(_ => repositoryIdParser)(_) 549 | 550 | } 551 | -------------------------------------------------------------------------------- /sbt: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # 3 | # A more capable sbt runner, coincidentally also called sbt. 4 | # Author: Paul Phillips 5 | # https://github.com/paulp/sbt-extras 6 | # 7 | # Generated from http://www.opensource.org/licenses/bsd-license.php 8 | # Copyright (c) 2011, Paul Phillips. All rights reserved. 9 | # 10 | # Redistribution and use in source and binary forms, with or without 11 | # modification, are permitted provided that the following conditions are 12 | # met: 13 | # 14 | # * Redistributions of source code must retain the above copyright 15 | # notice, this list of conditions and the following disclaimer. 16 | # * Redistributions in binary form must reproduce the above copyright 17 | # notice, this list of conditions and the following disclaimer in the 18 | # documentation and/or other materials provided with the distribution. 19 | # * Neither the name of the author nor the names of its contributors 20 | # may be used to endorse or promote products derived from this software 21 | # without specific prior written permission. 22 | # 23 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 24 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 25 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 26 | # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 27 | # HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 28 | # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED 29 | # TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 30 | # PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 31 | # LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 32 | # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 33 | # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 34 | 35 | set -o pipefail 36 | 37 | declare -r sbt_release_version="1.10.2" 38 | declare -r sbt_unreleased_version="1.10.2" 39 | 40 | declare -r latest_213="2.13.15" 41 | declare -r latest_212="2.12.20" 42 | declare -r latest_211="2.11.12" 43 | declare -r latest_210="2.10.7" 44 | declare -r latest_29="2.9.3" 45 | declare -r latest_28="2.8.2" 46 | 47 | declare -r buildProps="project/build.properties" 48 | 49 | declare -r sbt_launch_ivy_release_repo="https://repo.typesafe.com/typesafe/ivy-releases" 50 | declare -r sbt_launch_ivy_snapshot_repo="https://repo.scala-sbt.org/scalasbt/ivy-snapshots" 51 | declare -r sbt_launch_mvn_release_repo="https://repo1.maven.org/maven2" 52 | declare -r sbt_launch_mvn_snapshot_repo="https://repo.scala-sbt.org/scalasbt/maven-snapshots" 53 | 54 | declare -r default_jvm_opts_common="-Xms512m -Xss2m -XX:MaxInlineLevel=18" 55 | declare -r noshare_opts="-Dsbt.global.base=project/.sbtboot -Dsbt.boot.directory=project/.boot -Dsbt.ivy.home=project/.ivy -Dsbt.coursier.home=project/.coursier" 56 | 57 | declare sbt_jar sbt_dir sbt_create sbt_version sbt_script sbt_new 58 | declare sbt_explicit_version 59 | declare verbose noshare batch trace_level 60 | 61 | declare java_cmd="java" 62 | declare sbt_launch_dir="$HOME/.sbt/launchers" 63 | declare sbt_launch_repo 64 | 65 | # pull -J and -D options to give to java. 66 | declare -a java_args scalac_args sbt_commands residual_args 67 | 68 | # args to jvm/sbt via files or environment variables 69 | declare -a extra_jvm_opts extra_sbt_opts 70 | 71 | echoerr() { echo >&2 "$@"; } 72 | vlog() { [[ -n "$verbose" ]] && echoerr "$@"; } 73 | die() { 74 | echo "Aborting: $*" 75 | exit 1 76 | } 77 | 78 | setTrapExit() { 79 | # save stty and trap exit, to ensure echo is re-enabled if we are interrupted. 80 | SBT_STTY="$(stty -g 2>/dev/null)" 81 | export SBT_STTY 82 | 83 | # restore stty settings (echo in particular) 84 | onSbtRunnerExit() { 85 | [ -t 0 ] || return 86 | vlog "" 87 | vlog "restoring stty: $SBT_STTY" 88 | stty "$SBT_STTY" 89 | } 90 | 91 | vlog "saving stty: $SBT_STTY" 92 | trap onSbtRunnerExit EXIT 93 | } 94 | 95 | # this seems to cover the bases on OSX, and someone will 96 | # have to tell me about the others. 97 | get_script_path() { 98 | local path="$1" 99 | [[ -L "$path" ]] || { 100 | echo "$path" 101 | return 102 | } 103 | 104 | local -r target="$(readlink "$path")" 105 | if [[ "${target:0:1}" == "/" ]]; then 106 | echo "$target" 107 | else 108 | echo "${path%/*}/$target" 109 | fi 110 | } 111 | 112 | script_path="$(get_script_path "${BASH_SOURCE[0]}")" 113 | declare -r script_path 114 | script_name="${script_path##*/}" 115 | declare -r script_name 116 | 117 | init_default_option_file() { 118 | local overriding_var="${!1}" 119 | local default_file="$2" 120 | if [[ ! -r "$default_file" && "$overriding_var" =~ ^@(.*)$ ]]; then 121 | local envvar_file="${BASH_REMATCH[1]}" 122 | if [[ -r "$envvar_file" ]]; then 123 | default_file="$envvar_file" 124 | fi 125 | fi 126 | echo "$default_file" 127 | } 128 | 129 | sbt_opts_file="$(init_default_option_file SBT_OPTS .sbtopts)" 130 | sbtx_opts_file="$(init_default_option_file SBTX_OPTS .sbtxopts)" 131 | jvm_opts_file="$(init_default_option_file JVM_OPTS .jvmopts)" 132 | 133 | build_props_sbt() { 134 | [[ -r "$buildProps" ]] && 135 | grep '^sbt\.version' "$buildProps" | tr '=\r' ' ' | awk '{ print $2; }' 136 | } 137 | 138 | set_sbt_version() { 139 | sbt_version="${sbt_explicit_version:-$(build_props_sbt)}" 140 | [[ -n "$sbt_version" ]] || sbt_version=$sbt_release_version 141 | export sbt_version 142 | } 143 | 144 | url_base() { 145 | local version="$1" 146 | 147 | case "$version" in 148 | 0.7.*) echo "https://storage.googleapis.com/google-code-archive-downloads/v2/code.google.com/simple-build-tool" ;; 149 | 0.10.*) echo "$sbt_launch_ivy_release_repo" ;; 150 | 0.11.[12]) echo "$sbt_launch_ivy_release_repo" ;; 151 | 0.*-[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]) # ie "*-yyyymmdd-hhMMss" 152 | echo "$sbt_launch_ivy_snapshot_repo" ;; 153 | 0.*) echo "$sbt_launch_ivy_release_repo" ;; 154 | *-[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]T[0-9][0-9][0-9][0-9][0-9][0-9]) # ie "*-yyyymmddThhMMss" 155 | echo "$sbt_launch_mvn_snapshot_repo" ;; 156 | *) echo "$sbt_launch_mvn_release_repo" ;; 157 | esac 158 | } 159 | 160 | make_url() { 161 | local version="$1" 162 | 163 | local base="${sbt_launch_repo:-$(url_base "$version")}" 164 | 165 | case "$version" in 166 | 0.7.*) echo "$base/sbt-launch-0.7.7.jar" ;; 167 | 0.10.*) echo "$base/org.scala-tools.sbt/sbt-launch/$version/sbt-launch.jar" ;; 168 | 0.11.[12]) echo "$base/org.scala-tools.sbt/sbt-launch/$version/sbt-launch.jar" ;; 169 | 0.*) echo "$base/org.scala-sbt/sbt-launch/$version/sbt-launch.jar" ;; 170 | *) echo "$base/org/scala-sbt/sbt-launch/$version/sbt-launch-${version}.jar" ;; 171 | esac 172 | } 173 | 174 | addJava() { 175 | vlog "[addJava] arg = '$1'" 176 | java_args+=("$1") 177 | } 178 | addSbt() { 179 | vlog "[addSbt] arg = '$1'" 180 | sbt_commands+=("$1") 181 | } 182 | addScalac() { 183 | vlog "[addScalac] arg = '$1'" 184 | scalac_args+=("$1") 185 | } 186 | addResidual() { 187 | vlog "[residual] arg = '$1'" 188 | residual_args+=("$1") 189 | } 190 | 191 | addResolver() { addSbt "set resolvers += $1"; } 192 | 193 | addDebugger() { addJava "-Xdebug" && addJava "-Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=$1"; } 194 | 195 | setThisBuild() { 196 | vlog "[addBuild] args = '$*'" 197 | local key="$1" && shift 198 | addSbt "set $key in ThisBuild := $*" 199 | } 200 | setScalaVersion() { 201 | [[ "$1" == *"-SNAPSHOT" ]] && addResolver 'Resolver.sonatypeRepo("snapshots")' 202 | addSbt "++ $1" 203 | } 204 | setJavaHome() { 205 | java_cmd="$1/bin/java" 206 | setThisBuild javaHome "_root_.scala.Some(file(\"$1\"))" 207 | export JAVA_HOME="$1" 208 | export JDK_HOME="$1" 209 | export PATH="$JAVA_HOME/bin:$PATH" 210 | } 211 | 212 | getJavaVersion() { 213 | local -r str=$("$1" -version 2>&1 | grep -E -e '(java|openjdk) version' | awk '{ print $3 }' | tr -d '"') 214 | 215 | # java -version on java8 says 1.8.x 216 | # but on 9 and 10 it's 9.x.y and 10.x.y. 217 | if [[ "$str" =~ ^1\.([0-9]+)(\..*)?$ ]]; then 218 | echo "${BASH_REMATCH[1]}" 219 | # Fixes https://github.com/dwijnand/sbt-extras/issues/326 220 | elif [[ "$str" =~ ^([0-9]+)(\..*)?(-ea)?$ ]]; then 221 | echo "${BASH_REMATCH[1]}" 222 | elif [[ -n "$str" ]]; then 223 | echoerr "Can't parse java version from: $str" 224 | fi 225 | } 226 | 227 | checkJava() { 228 | # Warn if there is a Java version mismatch between PATH and JAVA_HOME/JDK_HOME 229 | 230 | [[ -n "$JAVA_HOME" && -e "$JAVA_HOME/bin/java" ]] && java="$JAVA_HOME/bin/java" 231 | [[ -n "$JDK_HOME" && -e "$JDK_HOME/lib/tools.jar" ]] && java="$JDK_HOME/bin/java" 232 | 233 | if [[ -n "$java" ]]; then 234 | pathJavaVersion=$(getJavaVersion java) 235 | homeJavaVersion=$(getJavaVersion "$java") 236 | if [[ "$pathJavaVersion" != "$homeJavaVersion" ]]; then 237 | echoerr "Warning: Java version mismatch between PATH and JAVA_HOME/JDK_HOME, sbt will use the one in PATH" 238 | echoerr " Either: fix your PATH, remove JAVA_HOME/JDK_HOME or use -java-home" 239 | echoerr " java version from PATH: $pathJavaVersion" 240 | echoerr " java version from JAVA_HOME/JDK_HOME: $homeJavaVersion" 241 | fi 242 | fi 243 | } 244 | 245 | java_version() { 246 | local -r version=$(getJavaVersion "$java_cmd") 247 | vlog "Detected Java version: $version" 248 | echo "$version" 249 | } 250 | 251 | is_apple_silicon() { [[ "$(uname -s)" == "Darwin" && "$(uname -m)" == "arm64" ]]; } 252 | 253 | # MaxPermSize critical on pre-8 JVMs but incurs noisy warning on 8+ 254 | default_jvm_opts() { 255 | local -r v="$(java_version)" 256 | if [[ $v -ge 17 ]]; then 257 | echo "$default_jvm_opts_common" 258 | elif [[ $v -ge 10 ]]; then 259 | if is_apple_silicon; then 260 | # As of Dec 2020, JVM for Apple Silicon (M1) doesn't support JVMCI 261 | echo "$default_jvm_opts_common" 262 | else 263 | echo "$default_jvm_opts_common -XX:+UnlockExperimentalVMOptions -XX:+UseJVMCICompiler" 264 | fi 265 | elif [[ $v -ge 8 ]]; then 266 | echo "$default_jvm_opts_common" 267 | else 268 | echo "-XX:MaxPermSize=384m $default_jvm_opts_common" 269 | fi 270 | } 271 | 272 | execRunner() { 273 | # print the arguments one to a line, quoting any containing spaces 274 | vlog "# Executing command line:" && { 275 | for arg; do 276 | if [[ -n "$arg" ]]; then 277 | if printf "%s\n" "$arg" | grep -q ' '; then 278 | printf >&2 "\"%s\"\n" "$arg" 279 | else 280 | printf >&2 "%s\n" "$arg" 281 | fi 282 | fi 283 | done 284 | vlog "" 285 | } 286 | 287 | setTrapExit 288 | 289 | if [[ -n "$batch" ]]; then 290 | "$@" /dev/null 2>&1; then 312 | curl --fail --silent --location "$url" --output "$jar" 313 | elif command -v wget >/dev/null 2>&1; then 314 | wget -q -O "$jar" "$url" 315 | fi 316 | } && [[ -r "$jar" ]] 317 | } 318 | 319 | acquire_sbt_jar() { 320 | { 321 | sbt_jar="$(jar_file "$sbt_version")" 322 | [[ -r "$sbt_jar" ]] 323 | } || { 324 | sbt_jar="$HOME/.ivy2/local/org.scala-sbt/sbt-launch/$sbt_version/jars/sbt-launch.jar" 325 | [[ -r "$sbt_jar" ]] 326 | } || { 327 | sbt_jar="$(jar_file "$sbt_version")" 328 | jar_url="$(make_url "$sbt_version")" 329 | 330 | echoerr "Downloading sbt launcher for ${sbt_version}:" 331 | echoerr " From ${jar_url}" 332 | echoerr " To ${sbt_jar}" 333 | 334 | download_url "${jar_url}" "${sbt_jar}" 335 | 336 | case "${sbt_version}" in 337 | 0.*) 338 | vlog "SBT versions < 1.0 do not have published MD5 checksums, skipping check" 339 | echo "" 340 | ;; 341 | *) verify_sbt_jar "${sbt_jar}" ;; 342 | esac 343 | } 344 | } 345 | 346 | verify_sbt_jar() { 347 | local jar="${1}" 348 | local md5="${jar}.md5" 349 | md5url="$(make_url "${sbt_version}").md5" 350 | 351 | echoerr "Downloading sbt launcher ${sbt_version} md5 hash:" 352 | echoerr " From ${md5url}" 353 | echoerr " To ${md5}" 354 | 355 | download_url "${md5url}" "${md5}" >/dev/null 2>&1 356 | 357 | if command -v md5sum >/dev/null 2>&1; then 358 | if echo "$(cat "${md5}") ${jar}" | md5sum -c -; then 359 | rm -rf "${md5}" 360 | return 0 361 | else 362 | echoerr "Checksum does not match" 363 | return 1 364 | fi 365 | elif command -v md5 >/dev/null 2>&1; then 366 | if [ "$(md5 -q "${jar}")" == "$(cat "${md5}")" ]; then 367 | rm -rf "${md5}" 368 | return 0 369 | else 370 | echoerr "Checksum does not match" 371 | return 1 372 | fi 373 | elif command -v openssl >/dev/null 2>&1; then 374 | if [ "$(openssl md5 -r "${jar}" | awk '{print $1}')" == "$(cat "${md5}")" ]; then 375 | rm -rf "${md5}" 376 | return 0 377 | else 378 | echoerr "Checksum does not match" 379 | return 1 380 | fi 381 | else 382 | echoerr "Could not find an MD5 command" 383 | return 1 384 | fi 385 | } 386 | 387 | usage() { 388 | set_sbt_version 389 | cat < display stack traces with a max of frames (default: -1, traces suppressed) 402 | -debug-inc enable debugging log for the incremental compiler 403 | -no-colors disable ANSI color codes 404 | -sbt-create start sbt even if current directory contains no sbt project 405 | -sbt-dir path to global settings/plugins directory (default: ~/.sbt/) 406 | -sbt-boot path to shared boot directory (default: ~/.sbt/boot in 0.11+) 407 | -ivy path to local Ivy repository (default: ~/.ivy2) 408 | -no-share use all local caches; no sharing 409 | -offline put sbt in offline mode 410 | -jvm-debug Turn on JVM debugging, open at the given port. 411 | -batch Disable interactive mode 412 | -prompt Set the sbt prompt; in expr, 's' is the State and 'e' is Extracted 413 | -script Run the specified file as a scala script 414 | 415 | # sbt version (default: sbt.version from $buildProps if present, otherwise $sbt_release_version) 416 | -sbt-version use the specified version of sbt (default: $sbt_release_version) 417 | -sbt-force-latest force the use of the latest release of sbt: $sbt_release_version 418 | -sbt-dev use the latest pre-release version of sbt: $sbt_unreleased_version 419 | -sbt-jar use the specified jar as the sbt launcher 420 | -sbt-launch-dir directory to hold sbt launchers (default: $sbt_launch_dir) 421 | -sbt-launch-repo repo url for downloading sbt launcher jar (default: $(url_base "$sbt_version")) 422 | 423 | # scala version (default: as chosen by sbt) 424 | -28 use $latest_28 425 | -29 use $latest_29 426 | -210 use $latest_210 427 | -211 use $latest_211 428 | -212 use $latest_212 429 | -213 use $latest_213 430 | -scala-home use the scala build at the specified directory 431 | -scala-version use the specified version of scala 432 | -binary-version use the specified scala version when searching for dependencies 433 | 434 | # java version (default: java from PATH, currently $(java -version 2>&1 | grep version)) 435 | -java-home alternate JAVA_HOME 436 | 437 | # passing options to the jvm - note it does NOT use JAVA_OPTS due to pollution 438 | # The default set is used if JVM_OPTS is unset and no -jvm-opts file is found 439 | $(default_jvm_opts) 440 | JVM_OPTS environment variable holding either the jvm args directly, or 441 | the reference to a file containing jvm args if given path is prepended by '@' (e.g. '@/etc/jvmopts') 442 | Note: "@"-file is overridden by local '.jvmopts' or '-jvm-opts' argument. 443 | -jvm-opts file containing jvm args (if not given, .jvmopts in project root is used if present) 444 | -Dkey=val pass -Dkey=val directly to the jvm 445 | -J-X pass option -X directly to the jvm (-J is stripped) 446 | 447 | # passing options to sbt, OR to this runner 448 | SBT_OPTS environment variable holding either the sbt args directly, or 449 | the reference to a file containing sbt args if given path is prepended by '@' (e.g. '@/etc/sbtopts') 450 | Note: "@"-file is overridden by local '.sbtopts' or '-sbt-opts' argument. 451 | -sbt-opts file containing sbt args (if not given, .sbtopts in project root is used if present) 452 | -S-X add -X to sbt's scalacOptions (-S is stripped) 453 | 454 | # passing options exclusively to this runner 455 | SBTX_OPTS environment variable holding either the sbt-extras args directly, or 456 | the reference to a file containing sbt-extras args if given path is prepended by '@' (e.g. '@/etc/sbtxopts') 457 | Note: "@"-file is overridden by local '.sbtxopts' or '-sbtx-opts' argument. 458 | -sbtx-opts file containing sbt-extras args (if not given, .sbtxopts in project root is used if present) 459 | EOM 460 | exit 0 461 | } 462 | 463 | process_args() { 464 | require_arg() { 465 | local type="$1" 466 | local opt="$2" 467 | local arg="$3" 468 | 469 | if [[ -z "$arg" ]] || [[ "${arg:0:1}" == "-" ]]; then 470 | die "$opt requires <$type> argument" 471 | fi 472 | } 473 | while [[ $# -gt 0 ]]; do 474 | case "$1" in 475 | -h | -help) usage ;; 476 | -v) verbose=true && shift ;; 477 | -d) addSbt "--debug" && shift ;; 478 | -w) addSbt "--warn" && shift ;; 479 | -q) addSbt "--error" && shift ;; 480 | -x) shift ;; # currently unused 481 | -trace) require_arg integer "$1" "$2" && trace_level="$2" && shift 2 ;; 482 | -debug-inc) addJava "-Dxsbt.inc.debug=true" && shift ;; 483 | 484 | -no-colors) addJava "-Dsbt.log.noformat=true" && addJava "-Dsbt.color=false" && shift ;; 485 | -sbt-create) sbt_create=true && shift ;; 486 | -sbt-dir) require_arg path "$1" "$2" && sbt_dir="$2" && shift 2 ;; 487 | -sbt-boot) require_arg path "$1" "$2" && addJava "-Dsbt.boot.directory=$2" && shift 2 ;; 488 | -ivy) require_arg path "$1" "$2" && addJava "-Dsbt.ivy.home=$2" && shift 2 ;; 489 | -no-share) noshare=true && shift ;; 490 | -offline) addSbt "set offline in Global := true" && shift ;; 491 | -jvm-debug) require_arg port "$1" "$2" && addDebugger "$2" && shift 2 ;; 492 | -batch) batch=true && shift ;; 493 | -prompt) require_arg "expr" "$1" "$2" && setThisBuild shellPrompt "(s => { val e = Project.extract(s) ; $2 })" && shift 2 ;; 494 | -script) require_arg file "$1" "$2" && sbt_script="$2" && addJava "-Dsbt.main.class=sbt.ScriptMain" && shift 2 ;; 495 | 496 | -sbt-version) require_arg version "$1" "$2" && sbt_explicit_version="$2" && shift 2 ;; 497 | -sbt-force-latest) sbt_explicit_version="$sbt_release_version" && shift ;; 498 | -sbt-dev) sbt_explicit_version="$sbt_unreleased_version" && shift ;; 499 | -sbt-jar) require_arg path "$1" "$2" && sbt_jar="$2" && shift 2 ;; 500 | -sbt-launch-dir) require_arg path "$1" "$2" && sbt_launch_dir="$2" && shift 2 ;; 501 | -sbt-launch-repo) require_arg path "$1" "$2" && sbt_launch_repo="$2" && shift 2 ;; 502 | 503 | -28) setScalaVersion "$latest_28" && shift ;; 504 | -29) setScalaVersion "$latest_29" && shift ;; 505 | -210) setScalaVersion "$latest_210" && shift ;; 506 | -211) setScalaVersion "$latest_211" && shift ;; 507 | -212) setScalaVersion "$latest_212" && shift ;; 508 | -213) setScalaVersion "$latest_213" && shift ;; 509 | 510 | -scala-version) require_arg version "$1" "$2" && setScalaVersion "$2" && shift 2 ;; 511 | -binary-version) require_arg version "$1" "$2" && setThisBuild scalaBinaryVersion "\"$2\"" && shift 2 ;; 512 | -scala-home) require_arg path "$1" "$2" && setThisBuild scalaHome "_root_.scala.Some(file(\"$2\"))" && shift 2 ;; 513 | -java-home) require_arg path "$1" "$2" && setJavaHome "$2" && shift 2 ;; 514 | -sbt-opts) require_arg path "$1" "$2" && sbt_opts_file="$2" && shift 2 ;; 515 | -sbtx-opts) require_arg path "$1" "$2" && sbtx_opts_file="$2" && shift 2 ;; 516 | -jvm-opts) require_arg path "$1" "$2" && jvm_opts_file="$2" && shift 2 ;; 517 | 518 | -D*) addJava "$1" && shift ;; 519 | -J*) addJava "${1:2}" && shift ;; 520 | -S*) addScalac "${1:2}" && shift ;; 521 | 522 | new) sbt_new=true && : ${sbt_explicit_version:=$sbt_release_version} && addResidual "$1" && shift ;; 523 | 524 | *) addResidual "$1" && shift ;; 525 | esac 526 | done 527 | } 528 | 529 | # process the direct command line arguments 530 | process_args "$@" 531 | 532 | # skip #-styled comments and blank lines 533 | readConfigFile() { 534 | local end=false 535 | until $end; do 536 | read -r || end=true 537 | [[ $REPLY =~ ^# ]] || [[ -z $REPLY ]] || echo "$REPLY" 538 | done <"$1" 539 | } 540 | 541 | # if there are file/environment sbt_opts, process again so we 542 | # can supply args to this runner 543 | if [[ -r "$sbt_opts_file" ]]; then 544 | vlog "Using sbt options defined in file $sbt_opts_file" 545 | while read -r opt; do extra_sbt_opts+=("$opt"); done < <(readConfigFile "$sbt_opts_file") 546 | elif [[ -n "$SBT_OPTS" && ! ("$SBT_OPTS" =~ ^@.*) ]]; then 547 | vlog "Using sbt options defined in variable \$SBT_OPTS" 548 | IFS=" " read -r -a extra_sbt_opts <<<"$SBT_OPTS" 549 | else 550 | vlog "No extra sbt options have been defined" 551 | fi 552 | 553 | # if there are file/environment sbtx_opts, process again so we 554 | # can supply args to this runner 555 | if [[ -r "$sbtx_opts_file" ]]; then 556 | vlog "Using sbt options defined in file $sbtx_opts_file" 557 | while read -r opt; do extra_sbt_opts+=("$opt"); done < <(readConfigFile "$sbtx_opts_file") 558 | elif [[ -n "$SBTX_OPTS" && ! ("$SBTX_OPTS" =~ ^@.*) ]]; then 559 | vlog "Using sbt options defined in variable \$SBTX_OPTS" 560 | IFS=" " read -r -a extra_sbt_opts <<<"$SBTX_OPTS" 561 | else 562 | vlog "No extra sbt options have been defined" 563 | fi 564 | 565 | [[ -n "${extra_sbt_opts[*]}" ]] && process_args "${extra_sbt_opts[@]}" 566 | 567 | # reset "$@" to the residual args 568 | set -- "${residual_args[@]}" 569 | argumentCount=$# 570 | 571 | # set sbt version 572 | set_sbt_version 573 | 574 | checkJava 575 | 576 | # only exists in 0.12+ 577 | setTraceLevel() { 578 | case "$sbt_version" in 579 | "0.7."* | "0.10."* | "0.11."*) echoerr "Cannot set trace level in sbt version $sbt_version" ;; 580 | *) setThisBuild traceLevel "$trace_level" ;; 581 | esac 582 | } 583 | 584 | # set scalacOptions if we were given any -S opts 585 | [[ ${#scalac_args[@]} -eq 0 ]] || addSbt "set scalacOptions in ThisBuild += \"${scalac_args[*]}\"" 586 | 587 | [[ -n "$sbt_explicit_version" && -z "$sbt_new" ]] && addJava "-Dsbt.version=$sbt_explicit_version" 588 | vlog "Detected sbt version $sbt_version" 589 | 590 | if [[ -n "$sbt_script" ]]; then 591 | residual_args=("$sbt_script" "${residual_args[@]}") 592 | else 593 | # no args - alert them there's stuff in here 594 | ((argumentCount > 0)) || { 595 | vlog "Starting $script_name: invoke with -help for other options" 596 | residual_args=(shell) 597 | } 598 | fi 599 | 600 | # verify this is an sbt dir, -create was given or user attempts to run a scala script 601 | [[ -r ./build.sbt || -d ./project || -n "$sbt_create" || -n "$sbt_script" || -n "$sbt_new" ]] || { 602 | cat <