├── 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 | [](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 <