diff --git a/.scalafmt.conf b/.scalafmt.conf index bda502a5..965c7e9c 100644 --- a/.scalafmt.conf +++ b/.scalafmt.conf @@ -1,3 +1,4 @@ -maxColumn = 180 +version = 2.0.1 +maxColumn = 120 style = defaultWithAlign optIn.breaksInsideChains = true diff --git a/README.md b/README.md index fad060cc..732ccf91 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,27 @@ sbt-sonatype plugin ====== -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](http://repo1.maven.org/maven2/). __sbt-sonatype__ plugin enables two-step release of Scala/Java projects. +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](http://repo1.maven.org/maven2/). __sbt-sonatype__ plugin enables three-step release of Scala/Java projects. + + * `sonatypePrepare` + * Prepare a staging repository at Sonatype. It will also clean up previously created staging repositories. This step is added since sbt-sonatype 3.0 to resume the entire release process from scratch. + * `publishSigned` (with [sbt-pgp plugin](http://www.scala-sbt.org/sbt-pgp/)) + * Upload GPG signed artifacts to Sonatype repository + * `sonatypeRelease` + * Perform the close and release steps in the Sonatype Nexus repository. + + After these steps, your project will be synchronized to the Maven central (usually) within ten minutes. No longer need to enter the web interface of + [Sonatype Nexus repository](http://oss.sonatype.org/) to performe these release steps. - * First `publishSigned` (with [sbt-pgp plugin](http://www.scala-sbt.org/sbt-pgp/)) - * Next `sonatypeRelease` to perform the close and release steps in the Sonatype Nexus repository. - * Done. Your project will be synchronized to the Maven central within tens of minutes. No longer need to enter the web interface of - [Sonatype Nexus repository](http://oss.sonatype.org/). - [Release notes](ReleaseNotes.md) -- sbt-sonatype is available for sbt-0.13.5 or later. +- sbt-sonatype is available for sbt 1.x series. - You can also use sbt-sonatype for [publishing non-sbt projects](README.md#publishing-maven-projects) (e.g., Maven, Gradle, etc.) - ## Prerequisites * Create a Sonatype Repository account - * Follow the instruction in the [Central Repository documentation site](http://central.sonatype.org). + * Follow the instruction in the [Central Repository documentation site](http://central.sonatype.org). * Create a Sonatype account * Create a GPG key * Open a JIRA ticket to get a permission for synchronizing your project to the Central Repository (aka Maven Central). @@ -35,13 +40,13 @@ A sbt plugin for publishing your project to the Maven central repository through Import ***sbt-sonatype*** plugin and [sbt-pgp plugin](http://www.scala-sbt.org/sbt-pgp/) to use `sonatypeRelease` and `publishSigned` commands: ```scala -// For sbt 0.13.x (upto sbt-sonatype 2.3) +// For sbt 1.x (sbt-sonatype 2.3 or higher) addSbtPlugin("org.xerial.sbt" % "sbt-sonatype" % "(version)") -addSbtPlugin("com.jsuereth" % "sbt-pgp" % "1.0.0") +addSbtPlugin("com.jsuereth" % "sbt-pgp" % "2.0.0-M2") -// For sbt 1.2.x (sbt-sonatype 2.3 or higher) +// For sbt 0.13.x (upto sbt-sonatype 2.3) addSbtPlugin("org.xerial.sbt" % "sbt-sonatype" % "(version)") -addSbtPlugin("com.jsuereth" % "sbt-pgp" % "1.1.0") +addSbtPlugin("com.jsuereth" % "sbt-pgp" % "1.0.0") ``` * If downloading the sbt-sonatype plugin fails, check the repository in the Maven central: . It will be usually synced within 10 minutes. @@ -49,8 +54,11 @@ addSbtPlugin("com.jsuereth" % "sbt-pgp" % "1.1.0") ### build.sbt ```scala -// Add the default sonatype repository setting +// [Important] Use publishTo settings set by sbt-sonatype plugin publishTo := sonatypePublishTo.value + +// [Optional] If you need to manage unique session names, change this setting: +sonatypeSessionName := s"[sbt-sonatype] ${name.value} ${version.value}", ``` ### $HOME/.sbt/(sbt-version 0.13 or 1.0)/sonatype.sbt @@ -129,45 +137,56 @@ $ sbt sonatypeRelease ``` This command accesses [Sonatype Nexus REST API](https://oss.sonatype.org/nexus-staging-plugin/default/docs/index.html), then sends close and promote commands. -## Available Commands - -* __sonatypeList__ - * Show the list of staging repositories. -* __sonatypeOpen__ (description | sonatypeProfileName description) (since sbt-sonatype-1.1) - * Create a staging repository and set `sonatypeStagingRepositoryProfile` and `publishTo`. - * Although creating a staging repository does not result in email notifications, - the description will be reused for across lifecycle operations (Close, Promote, Drop) - to facilitate distinguishing email notifications sent by the repository by description. -* __sonatypeClose__ (repositoryId)? - * Close an open staging repository and set `sonatypeStagingRepositoryProfile` and - clear `publishTo` if it was set by __sonatypeOpen__. - * The `Staging Completed` email notification sent by the repository only includes the description - (if created with __sonatypeOpen__); it does not include the staging repository ID. -* __sonatypePromote__ (repositoryId)? - * Promote a closed staging repository and set `sonatypeStagingRepositoryProfile` and - clear `publishTo` if it was set by __sonatypeOpen__. - * The `Promotion Completed` email notification sent by the repository only includes the description - (if created with __sonatypeOpen__); it does not include the staging repository ID. -* __sonatypeDrop__ (repositoryId)? - * Drop an open or closed staging repository and set `sonatypeStagingRepositoryProfile` and - clear `publishTo` if it was set by __sonatypeOpen__. - * The email notification sent by the repository includes both the description - (if created with __sonatypeOpen__) and the staging repository ID. -* __sonatypeDropAll__ - * Drop all staging repositories. +## Commands + +### Basic Commands +* __sonatypePrepare__ + * Drop (if exists) and create a new staging repository using `sonatypeSessionName` as a unique key + * For cross-build projects, make sure running this command only once at the beginning of the release process. Then run `sonatypeOpen` for each build to reuse the already created stging repository. +* __sonatypeOpen__ + * Open the existing staging repository using `sonatypeSessionName` as a unique key. If it doesn't exist, create a new one. It will update`sonatypePublishTo` + * This command is useful to run `publishSigned` task in parallel. * __sonatypeRelease__ (repositoryId)? - * Close (if needed) and promote a staging repository and set `sonatypeStagingRepositoryProfile` and - clear `publishTo` if it was set by __sonatypeOpen__. - * The email notifications are those of __sonatypeClose__ (if applicable) and __sonatypePromote__. + * Close (if needed) and promote a staging repository. After this command, the uploaded artifacts will be synchronized to Maven central. + +### Batch Operations +* __sonatypeDropAll__ (sonatypeProfileName)? + * Drop all staging repositories. * __sonatypeReleaseAll__ (sonatypeProfileName)? * Close and promote all staging repositories (Useful for cross-building projects) + +## Others * __sonatypeStagingProfiles__ * Show the list of staging profiles, which include profileName information. * __sonatypeLog__ * Show the staging activity logs - -### Advanced Usage - +* __sonatypeClose__ + * Close the open staging repository (= requirement verification) +* __sonatypePromote__ + * Promote the closed staging repository (= sync to Maven central) +* __sonatypeDrop__ + * Drop an open or closed staging repository + +## Uploading Artifacts In Parallel + +Since sbt-sonatype 3.x, it supports session based release flows: + +### Single Module Projects + - sonatypePrepare + - publishSigned + - sonatypeRelease + +### Multi Module Projects + - Run `sonatypePrepare` in a single step. + - You must wait for the completion of this step + - Then, start uploading signed artifacts using multiple processes: + - P1: `; sonatypeOpen; publishSigned` + - P2: `; sonatypeOpen; publishSigned` + - P3: ... + - Wait for all upload completion + - Finally, run `sonatypeRelease` + +For sbt-sonatype 2.x: * [Example workflow for creating & publishing to a staging repository](workflow.md) ## Using with sbt-release plugin @@ -186,11 +205,12 @@ releaseProcess := Seq[ReleaseStep]( setReleaseVersion, commitReleaseVersion, tagRelease, + releaseStepCommand("sonatypePrepare"), // For non cross-build projects, use releaseStepCommand("publishSigned") releaseStepCommandAndRemaining("+publishSigned"), + releaseStepCommand("sonatypeRelease"), setNextVersion, commitNextVersion, - releaseStepCommand("sonatypeReleaseAll"), pushChanges ) ``` diff --git a/build.sbt b/build.sbt index 6739d7d5..61eeade8 100755 --- a/build.sbt +++ b/build.sbt @@ -16,7 +16,7 @@ import ReleaseTransformations._ -lazy val buildSettings = Seq( +lazy val buildSettings: Seq[Setting[_]] = Seq( organization := "org.xerial.sbt", organizationName := "Xerial project", organizationHomepage := Some(new URL("http://xerial.org/")), @@ -29,8 +29,8 @@ lazy val buildSettings = Seq( scriptedLaunchOpts := { scriptedLaunchOpts.value ++ Seq("-Xmx1024M", "-XX:MaxPermSize=256M", "-Dplugin.version=" + version.value) }, - crossSbtVersions := Vector("1.2.7"), - releaseCrossBuild := true, + crossSbtVersions := Vector("1.3.0"), + releaseCrossBuild := false, releaseTagName := { (version in ThisBuild).value }, releasePublishArtifactsAction := PgpKeys.publishSigned.value, releaseProcess := Seq[ReleaseStep]( @@ -41,23 +41,25 @@ lazy val buildSettings = Seq( setReleaseVersion, commitReleaseVersion, tagRelease, - releaseStepCommandAndRemaining("^ publishSigned"), + releaseStepCommand("sonatypePrepare"), + releaseStepCommandAndRemaining("publishSigned"), + releaseStepCommand("sonatypeRelease"), setNextVersion, commitNextVersion, - releaseStepCommand("sonatypeReleaseAll"), pushChanges ) ) // Project modules -lazy val sbtSonatype = Project( - id = "sbt-sonatype", - base = file(".") -).enablePlugins(ScriptedPlugin) - .settings(buildSettings) - .settings( - libraryDependencies ++= Seq( - "org.apache.httpcomponents" % "httpclient" % "4.2.6", - "org.scalatest" %% "scalatest" % "3.0.1" % "test" +lazy val sbtSonatype = + project + .withId("sbt-sonatype") + .in(file(".")) + .enablePlugins(ScriptedPlugin) + .settings( + buildSettings, + testFrameworks += new TestFramework("wvlet.airspec.Framework"), + libraryDependencies ++= Seq( + "org.apache.httpcomponents" % "httpclient" % "4.2.6" + ) ) - ) diff --git a/project/build.properties b/project/build.properties index ee0a5b57..080a737e 100755 --- a/project/build.properties +++ b/project/build.properties @@ -1,2 +1 @@ -sbt.version=1.2.7 - +sbt.version=1.3.0 diff --git a/project/plugins.sbt b/project/plugins.sbt index 8d613e8d..79f9108c 100755 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,8 +1,7 @@ addSbtPlugin("com.github.gseitz" % "sbt-release" % "1.0.7") -addSbtPlugin("org.xerial.sbt" % "sbt-sonatype" % "2.3") -addSbtPlugin("com.jsuereth" % "sbt-pgp" % "1.1.1") -addSbtPlugin("io.get-coursier" % "sbt-coursier" % "1.1.0-M7") -addSbtPlugin("com.geirsson" % "sbt-scalafmt" % "1.5.1") +addSbtPlugin("org.xerial.sbt" % "sbt-sonatype" % "3.0-SNAPSHOT") +addSbtPlugin("com.jsuereth" % "sbt-pgp" % "2.0.0-M2") +addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.0.3") libraryDependencies += "org.scala-sbt" %% "scripted-plugin" % sbtVersion.value diff --git a/sbt b/sbt index f0b5bddd..c7966f81 100755 --- a/sbt +++ b/sbt @@ -2,32 +2,34 @@ # # A more capable sbt runner, coincidentally also called sbt. # Author: Paul Phillips +# https://github.com/paulp/sbt-extras set -o pipefail -declare -r sbt_release_version="0.13.15" -declare -r sbt_unreleased_version="0.13.15" +declare -r sbt_release_version="1.2.8" +declare -r sbt_unreleased_version="1.3.0-RC1" -declare -r latest_212="2.12.1" -declare -r latest_211="2.11.11" -declare -r latest_210="2.10.6" +declare -r latest_213="2.13.0" +declare -r latest_212="2.12.9" +declare -r latest_211="2.11.12" +declare -r latest_210="2.10.7" declare -r latest_29="2.9.3" declare -r latest_28="2.8.2" declare -r buildProps="project/build.properties" -declare -r sbt_launch_ivy_release_repo="http://repo.typesafe.com/typesafe/ivy-releases" +declare -r sbt_launch_ivy_release_repo="https://repo.typesafe.com/typesafe/ivy-releases" declare -r sbt_launch_ivy_snapshot_repo="https://repo.scala-sbt.org/scalasbt/ivy-snapshots" -declare -r sbt_launch_mvn_release_repo="http://repo.scala-sbt.org/scalasbt/maven-releases" -declare -r sbt_launch_mvn_snapshot_repo="http://repo.scala-sbt.org/scalasbt/maven-snapshots" +declare -r sbt_launch_mvn_release_repo="https://repo.scala-sbt.org/scalasbt/maven-releases" +declare -r sbt_launch_mvn_snapshot_repo="https://repo.scala-sbt.org/scalasbt/maven-snapshots" -declare -r default_jvm_opts_common="-Xms512m -Xmx1536m -Xss2m" +declare -r default_jvm_opts_common="-Xms512m -Xss2m" declare -r noshare_opts="-Dsbt.global.base=project/.sbtboot -Dsbt.boot.directory=project/.boot -Dsbt.ivy.home=project/.ivy" declare sbt_jar sbt_dir sbt_create sbt_version sbt_script sbt_new declare sbt_explicit_version declare verbose noshare batch trace_level -declare sbt_saved_stty debugUs +declare debugUs declare java_cmd="java" declare sbt_launch_dir="$HOME/.sbt/launchers" @@ -41,21 +43,24 @@ declare -a extra_jvm_opts extra_sbt_opts echoerr () { echo >&2 "$@"; } vlog () { [[ -n "$verbose" ]] && echoerr "$@"; } -die () { echo "Aborting: $@" ; exit 1; } - -# restore stty settings (echo in particular) -onSbtRunnerExit() { - [[ -n "$sbt_saved_stty" ]] || return - vlog "" - vlog "restoring stty: $sbt_saved_stty" - stty "$sbt_saved_stty" - unset sbt_saved_stty -} +die () { echo "Aborting: $*" ; exit 1; } -# save stty and trap exit, to ensure echo is re-enabled if we are interrupted. -trap onSbtRunnerExit EXIT -sbt_saved_stty="$(stty -g 2>/dev/null)" -vlog "Saved stty: $sbt_saved_stty" +setTrapExit () { + # save stty and trap exit, to ensure echo is re-enabled if we are interrupted. + SBT_STTY="$(stty -g 2>/dev/null)" + export SBT_STTY + + # restore stty settings (echo in particular) + onSbtRunnerExit() { + [ -t 0 ] || return + vlog "" + vlog "restoring stty: $SBT_STTY" + stty "$SBT_STTY" + } + + vlog "saving stty: $SBT_STTY" + trap onSbtRunnerExit EXIT +} # this seems to cover the bases on OSX, and someone will # have to tell me about the others. @@ -63,7 +68,7 @@ get_script_path () { local path="$1" [[ -L "$path" ]] || { echo "$path" ; return; } - local target="$(readlink "$path")" + local -r target="$(readlink "$path")" if [[ "${target:0:1}" == "/" ]]; then echo "$target" else @@ -71,8 +76,10 @@ get_script_path () { fi } -declare -r script_path="$(get_script_path "$BASH_SOURCE")" -declare -r script_name="${script_path##*/}" +script_path="$(get_script_path "${BASH_SOURCE[0]}")" +declare -r script_path +script_name="${script_path##*/}" +declare -r script_name init_default_option_file () { local overriding_var="${!1}" @@ -86,29 +93,14 @@ init_default_option_file () { echo "$default_file" } -declare sbt_opts_file="$(init_default_option_file SBT_OPTS .sbtopts)" -declare jvm_opts_file="$(init_default_option_file JVM_OPTS .jvmopts)" +sbt_opts_file="$(init_default_option_file SBT_OPTS .sbtopts)" +jvm_opts_file="$(init_default_option_file JVM_OPTS .jvmopts)" build_props_sbt () { [[ -r "$buildProps" ]] && \ grep '^sbt\.version' "$buildProps" | tr '=\r' ' ' | awk '{ print $2; }' } -update_build_props_sbt () { - local ver="$1" - local old="$(build_props_sbt)" - - [[ -r "$buildProps" ]] && [[ "$ver" != "$old" ]] && { - perl -pi -e "s/^sbt\.version\b.*\$/sbt.version=${ver}/" "$buildProps" - grep -q '^sbt.version[ =]' "$buildProps" || printf "\nsbt.version=%s\n" "$ver" >> "$buildProps" - - vlog "!!!" - vlog "!!! Updated file $buildProps setting sbt.version to: $ver" - vlog "!!! Previous value was: $old" - vlog "!!!" - } -} - set_sbt_version () { sbt_version="${sbt_explicit_version:-$(build_props_sbt)}" [[ -n "$sbt_version" ]] || sbt_version=$sbt_release_version @@ -119,7 +111,7 @@ url_base () { local version="$1" case "$version" in - 0.7.*) echo "http://simple-build-tool.googlecode.com" ;; + 0.7.*) echo "https://simple-build-tool.googlecode.com" ;; 0.10.* ) echo "$sbt_launch_ivy_release_repo" ;; 0.11.[12]) echo "$sbt_launch_ivy_release_repo" ;; 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" @@ -141,7 +133,7 @@ make_url () { 0.10.* ) echo "$base/org.scala-tools.sbt/sbt-launch/$version/sbt-launch.jar" ;; 0.11.[12]) echo "$base/org.scala-tools.sbt/sbt-launch/$version/sbt-launch.jar" ;; 0.*) echo "$base/org.scala-sbt/sbt-launch/$version/sbt-launch.jar" ;; - *) echo "$base/org/scala-sbt/sbt-launch/$version/sbt-launch.jar" ;; + *) echo "$base/org/scala-sbt/sbt-launch/$version/sbt-launch-${version}.jar" ;; esac } @@ -153,9 +145,9 @@ addResidual () { vlog "[residual] arg = '$1'" ; residual_args+=("$1"); } addResolver () { addSbt "set resolvers += $1"; } addDebugger () { addJava "-Xdebug" ; addJava "-Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=$1"; } setThisBuild () { - vlog "[addBuild] args = '$@'" + vlog "[addBuild] args = '$*'" local key="$1" && shift - addSbt "set $key in ThisBuild := $@" + addSbt "set $key in ThisBuild := $*" } setScalaVersion () { [[ "$1" == *"-SNAPSHOT" ]] && addResolver 'Resolver.sonatypeRepo("snapshots")' @@ -169,7 +161,19 @@ setJavaHome () { export PATH="$JAVA_HOME/bin:$PATH" } -getJavaVersion() { "$1" -version 2>&1 | grep -E -e '(java|openjdk) version' | awk '{ print $3 }' | tr -d \"; } +getJavaVersion() { + local -r str=$("$1" -version 2>&1 | grep -E -e '(java|openjdk) version' | awk '{ print $3 }' | tr -d '"') + + # java -version on java8 says 1.8.x + # but on 9 and 10 it's 9.x.y and 10.x.y. + if [[ "$str" =~ ^1\.([0-9]+)\..*$ ]]; then + echo "${BASH_REMATCH[1]}" + elif [[ "$str" =~ ^([0-9]+)\..*$ ]]; then + echo "${BASH_REMATCH[1]}" + elif [[ -n "$str" ]]; then + echoerr "Can't parse java version from: $str" + fi +} checkJava() { # Warn if there is a Java version mismatch between PATH and JAVA_HOME/JDK_HOME @@ -190,14 +194,14 @@ checkJava() { } java_version () { - local version=$(getJavaVersion "$java_cmd") + local -r version=$(getJavaVersion "$java_cmd") vlog "Detected Java version: $version" - echo "${version:2:1}" + echo "$version" } # MaxPermSize critical on pre-8 JVMs but incurs noisy warning on 8+ default_jvm_opts () { - local v="$(java_version)" + local -r v="$(java_version)" if [[ $v -ge 8 ]]; then echo "$default_jvm_opts_common" else @@ -228,17 +232,22 @@ execRunner () { vlog "" } - [[ -n "$batch" ]] && exec /dev/null; then + if command -v curl > /dev/null 2>&1; then curl --fail --silent --location "$url" --output "$jar" - elif which wget >/dev/null; then + elif command -v wget > /dev/null 2>&1; then wget -q -O "$jar" "$url" fi } && [[ -r "$jar" ]] @@ -268,10 +273,57 @@ acquire_sbt_jar () { [[ -r "$sbt_jar" ]] } || { sbt_jar="$(jar_file "$sbt_version")" - download_url "$(make_url "$sbt_version")" "$sbt_jar" + jar_url="$(make_url "$sbt_version")" + + echoerr "Downloading sbt launcher for ${sbt_version}:" + echoerr " From ${jar_url}" + echoerr " To ${sbt_jar}" + + download_url "${jar_url}" "${sbt_jar}" + + case "${sbt_version}" in + 0.*) vlog "SBT versions < 1.0 do not have published MD5 checksums, skipping check"; echo "" ;; + *) verify_sbt_jar "${sbt_jar}" ;; + esac } } +verify_sbt_jar() { + local jar="${1}" + local md5="${jar}.md5" + + download_url "$(make_url "${sbt_version}").md5" "${md5}" > /dev/null 2>&1 + + if command -v md5sum > /dev/null 2>&1; then + if echo "$(cat "${md5}") ${jar}" | md5sum -c -; then + rm -rf "${md5}" + return 0 + else + echoerr "Checksum does not match" + return 1 + fi + elif command -v md5 > /dev/null 2>&1; then + if [ "$(md5 -q "${jar}")" == "$(cat "${md5}")" ]; then + rm -rf "${md5}" + return 0 + else + echoerr "Checksum does not match" + return 1 + fi + elif command -v openssl > /dev/null 2>&1; then + if [ "$(openssl md5 -r "${jar}" | awk '{print $1}')" == "$(cat "${md5}")" ]; then + rm -rf "${md5}" + return 0 + else + echoerr "Checksum does not match" + return 1 + fi + else + echoerr "Could not find an MD5 command" + return 1 + fi +} + usage () { set_sbt_version cat < use the scala build at the specified directory -scala-version use the specified version of scala -binary-version use the specified scala version when searching for dependencies @@ -399,6 +452,7 @@ process_args () { -210) setScalaVersion "$latest_210" && shift ;; -211) setScalaVersion "$latest_211" && shift ;; -212) setScalaVersion "$latest_212" && shift ;; + -213) setScalaVersion "$latest_213" && shift ;; new) sbt_new=true && : ${sbt_explicit_version:=$sbt_release_version} && addResidual "$1" && shift ;; *) addResidual "$1" && shift ;; esac @@ -412,7 +466,7 @@ process_args "$@" readConfigFile() { local end=false until $end; do - read || end=true + read -r || end=true [[ $REPLY =~ ^# ]] || [[ -z $REPLY ]] || echo "$REPLY" done < "$1" } @@ -421,10 +475,10 @@ readConfigFile() { # can supply args to this runner if [[ -r "$sbt_opts_file" ]]; then vlog "Using sbt options defined in file $sbt_opts_file" - while read opt; do extra_sbt_opts+=("$opt"); done < <(readConfigFile "$sbt_opts_file") + while read -r opt; do extra_sbt_opts+=("$opt"); done < <(readConfigFile "$sbt_opts_file") elif [[ -n "$SBT_OPTS" && ! ("$SBT_OPTS" =~ ^@.*) ]]; then vlog "Using sbt options defined in variable \$SBT_OPTS" - extra_sbt_opts=( $SBT_OPTS ) + IFS=" " read -r -a extra_sbt_opts <<< "$SBT_OPTS" else vlog "No extra sbt options have been defined" fi @@ -444,19 +498,18 @@ checkJava setTraceLevel() { case "$sbt_version" in "0.7."* | "0.10."* | "0.11."* ) echoerr "Cannot set trace level in sbt version $sbt_version" ;; - *) setThisBuild traceLevel $trace_level ;; + *) setThisBuild traceLevel "$trace_level" ;; esac } # set scalacOptions if we were given any -S opts -[[ ${#scalac_args[@]} -eq 0 ]] || addSbt "set scalacOptions in ThisBuild += \"${scalac_args[@]}\"" +[[ ${#scalac_args[@]} -eq 0 ]] || addSbt "set scalacOptions in ThisBuild += \"${scalac_args[*]}\"" -# Update build.properties on disk to set explicit version - sbt gives us no choice -[[ -n "$sbt_explicit_version" && -z "$sbt_new" ]] && update_build_props_sbt "$sbt_explicit_version" +[[ -n "$sbt_explicit_version" && -z "$sbt_new" ]] && addJava "-Dsbt.version=$sbt_explicit_version" vlog "Detected sbt version $sbt_version" if [[ -n "$sbt_script" ]]; then - residual_args=( $sbt_script ${residual_args[@]} ) + residual_args=( "$sbt_script" "${residual_args[@]}" ) else # no args - alert them there's stuff in here (( argumentCount > 0 )) || { @@ -477,6 +530,7 @@ EOM } # pick up completion if present; todo +# shellcheck disable=SC1091 [[ -r .sbt_completion.sh ]] && source .sbt_completion.sh # directory to store sbt launchers @@ -486,7 +540,7 @@ EOM # no jar? download it. [[ -r "$sbt_jar" ]] || acquire_sbt_jar || { # still no jar? uh-oh. - echo "Download failed. Obtain the jar manually and place it at $sbt_jar" + echo "Could not download and verify the launcher. Obtain the jar manually and place it at $sbt_jar" exit 1 } @@ -511,13 +565,13 @@ fi if [[ -r "$jvm_opts_file" ]]; then vlog "Using jvm options defined in file $jvm_opts_file" - while read opt; do extra_jvm_opts+=("$opt"); done < <(readConfigFile "$jvm_opts_file") + while read -r opt; do extra_jvm_opts+=("$opt"); done < <(readConfigFile "$jvm_opts_file") elif [[ -n "$JVM_OPTS" && ! ("$JVM_OPTS" =~ ^@.*) ]]; then vlog "Using jvm options defined in \$JVM_OPTS variable" - extra_jvm_opts=( $JVM_OPTS ) + IFS=" " read -r -a extra_jvm_opts <<< "$JVM_OPTS" else vlog "Using default jvm options" - extra_jvm_opts=( $(default_jvm_opts) ) + IFS=" " read -r -a extra_jvm_opts <<< "$(default_jvm_opts)" fi # traceLevel is 0.12+ @@ -539,13 +593,12 @@ main () { # we're not going to print those lines anyway. We strip that bit of # line noise, but leave the other codes to preserve color. mainFiltered () { - local ansiOverwrite='\r\x1BM\x1B[2K' - local excludeRegex=$(egrep -v '^#|^$' ~/.sbtignore | paste -sd'|' -) + local -r excludeRegex=$(grep -E -v '^#|^$' ~/.sbtignore | paste -sd'|' -) echoLine () { - local line="$1" - local line1="$(echo "$line" | sed 's/\r\x1BM\x1B\[2K//g')" # This strips the OverwriteLine code. - local line2="$(echo "$line1" | sed 's/\x1B\[[0-9;]*[JKmsu]//g')" # This strips all codes - we test regexes against this. + local -r line="$1" + local -r line1="${line//\r\x1BM\x1B\[2K//g}" # This strips the OverwriteLine code. + local -r line2="${line1//\x1B\[[0-9;]*[JKmsu]//g}" # This strips all codes - we test regexes against this. if [[ $line2 =~ $excludeRegex ]]; then [[ -n $debugUs ]] && echo "[X] $line1" @@ -562,7 +615,7 @@ mainFiltered () { # Obviously this is super ad hoc but I don't know how to improve on it. Testing whether # stdin is a terminal is useless because most of my use cases for this filtering are # exactly when I'm at a terminal, running sbt non-interactively. -shouldFilter () { [[ -f ~/.sbtignore ]] && ! egrep -q '\b(shell|console|consoleProject)\b' <<<"${residual_args[@]}"; } +shouldFilter () { [[ -f ~/.sbtignore ]] && ! grep -E -q '\b(shell|console|consoleProject)\b' <<<"${residual_args[@]}"; } # run sbt if shouldFilter; then mainFiltered; else main; fi diff --git a/src/main/scala/xerial/sbt/NexusClient.scala b/src/main/scala/xerial/sbt/NexusClient.scala new file mode 100644 index 00000000..2146b39e --- /dev/null +++ b/src/main/scala/xerial/sbt/NexusClient.scala @@ -0,0 +1,646 @@ +package xerial.sbt + +import java.io.IOException + +import org.apache.http.auth.{AuthScope, UsernamePasswordCredentials} +import org.apache.http.client.HttpClient +import org.apache.http.client.methods.{HttpGet, HttpPost} +import org.apache.http.entity.StringEntity +import org.apache.http.impl.client.DefaultHttpClient +import org.apache.http.{HttpResponse, HttpStatus} +import sbt.{Credentials, DirectCredentials, Logger} + +import scala.io.Source +import scala.xml.{Utility, XML} + +/** + * Interface to access the REST API of Nexus + * @param log + * @param repositoryUrl + * @param profileName + * @param cred + * @param credentialHost + */ +class NexusRESTService( + log: Logger, + repositoryUrl: String, + val profileName: String, + cred: Seq[Credentials], + credentialHost: String +) { + import xerial.sbt.NexusRESTService._ + + val monitor = new ActivityMonitor(log) + + def findTargetRepository(command: CommandType, arg: Option[String]): StagingRepositoryProfile = { + val repos = command match { + case Close => openRepositories + case Promote => closedRepositories + case Drop => stagingRepositoryProfiles() + case CloseAndPromote => stagingRepositoryProfiles() + } + if (repos.isEmpty) { + if (stagingProfiles.isEmpty) { + log.error(s"No staging profile found for $profileName") + log.error("Have you requested a staging profile and successfully published your signed artifact there?") + throw new IllegalStateException(s"No staging profile found for $profileName") + } else { + throw new IllegalStateException(command.errNotFound) + } + } + + def findSpecifiedInArg(target: String) = { + repos.find(_.repositoryId == target).getOrElse { + log.error(s"Repository $target is not found") + log.error(s"Specify one of the repository ids in:\n${repos.mkString("\n")}") + throw new IllegalArgumentException(s"Repository $target is not found") + } + } + + arg.map(findSpecifiedInArg).getOrElse { + if (repos.size > 1) { + log.error(s"Multiple repositories are found:\n${repos.mkString("\n")}") + log.error( + s"Specify one of the repository ids in the command line or run sonatypeDropAll to cleanup repositories") + throw new IllegalStateException("Found multiple staging repositories") + } else { + repos.head + } + } + } + + def openRepositories = stagingRepositoryProfiles().filter(_.isOpen).sortBy(_.repositoryId) + def closedRepositories = stagingRepositoryProfiles().filter(_.isClosed).sortBy(_.repositoryId) + + private def repoBase(url: String) = if (url.endsWith("/")) url.dropRight(1) else url + private val repo = { + val url = repoBase(repositoryUrl) + log.info(s"Nexus repository URL: $url") + log.info(s"sonatypeProfileName = ${profileName}") + url + } + + def Get[U](path: String)(body: HttpResponse => U): U = { + val req = new HttpGet(s"${repo}$path") + req.addHeader("Content-Type", "application/xml") + + val retry = new ExponentialBackOffRetry(initialWaitSeq = 0) + var toContinue = true + var response: HttpResponse = null + var ret: Any = null + while (toContinue && retry.hasNext) { + withHttpClient { client => + response = client.execute(req) + log.debug(s"Status line: ${response.getStatusLine}") + response.getStatusLine.getStatusCode match { + case HttpStatus.SC_OK => + toContinue = false + ret = body(response) + case HttpStatus.SC_INTERNAL_SERVER_ERROR => + log.warn(s"Received 500 error: ${response.getStatusLine}. Retrying...") + retry.doWait + case _ => + throw new IOException(s"Failed to retrieve data from $path: ${response.getStatusLine}") + } + } + } + if (ret == null) { + throw new IOException(s"Failed to retrieve data from $path") + } + ret.asInstanceOf[U] + } + + def IgnoreEntityContent(in: java.io.InputStream): Unit = () + + def Post(path: String, bodyXML: String, contentHandler: java.io.InputStream => Unit = IgnoreEntityContent) = { + val req = new HttpPost(s"${repo}$path") + req.setEntity(new StringEntity(bodyXML)) + req.addHeader("Content-Type", "application/xml") + + val retry = new ExponentialBackOffRetry(initialWaitSeq = 0) + var response: HttpResponse = null + var toContinue = true + while (toContinue && retry.hasNext) { + withHttpClient { client => + response = client.execute(req) + response.getStatusLine.getStatusCode match { + case HttpStatus.SC_INTERNAL_SERVER_ERROR => + log.warn(s"Received 500 error: ${response.getStatusLine}. Retrying...") + retry.doWait + case _ => + log.debug(s"Status line: ${response.getStatusLine}") + toContinue = false + contentHandler(response.getEntity.getContent) + } + } + } + if (toContinue) { + throw new IOException(s"Failed to retrieve data from $path") + } + response + } + + private def withHttpClient[U](body: HttpClient => U): U = { + val credt: DirectCredentials = Credentials + .forHost(cred, credentialHost) + .getOrElse { + throw new IllegalStateException( + s"No credential is found for $credentialHost. Prepare ~/.sbt/(sbt_version)/sonatype.sbt file." + ) + } + + val client = new DefaultHttpClient() + try { + val user = credt.userName + val passwd = credt.passwd + client.getCredentialsProvider.setCredentials( + new AuthScope(credt.host, AuthScope.ANY_PORT), + new UsernamePasswordCredentials(user, passwd) + ) + body(client) + } finally client.getConnectionManager.shutdown() + } + + def openOrCreateByKey(descriptionKey: String): StagingRepositoryProfile = { + // Find the already opened profile or create a new one + val repos = findStagingRepositoryProfilesWithKey(descriptionKey) + if (repos.size > 1) { + throw new IllegalStateException( + s"Multiple staging repositories for ${descriptionKey} exists. Run sonatypeDropAll first to clean up old repositories") + } else if (repos.size == 1) { + val repo = repos.head + log.info(s"Found a staging repository ${repo}") + repo + } else { + // Create a new staging repository by appending [sbt-sonatype] prefix to its description so that we can find the repository id later + log.info(s"No staging repository for ${descriptionKey} is found. Create a new one.") + createStage(descriptionKey) + } + } + + def dropIfExistsByKey(descriptionKey: String): Option[StagingRepositoryProfile] = { + // Drop the staging repository if exists + val repos = findStagingRepositoryProfilesWithKey(descriptionKey) + if (repos.isEmpty) { + log.info(s"No staging repository for ${descriptionKey} is found") + None + } else { + repos.map { repo => + log.info(s"Found a staging repository ${repo}") + dropStage(repo) + }.lastOption + } + } + + def findStagingRepositoryProfilesWithKey(descriptionKey: String): Seq[StagingRepositoryProfile] = { + stagingRepositoryProfiles(warnIfMissing = false).filter(_.description == descriptionKey) + } + + def stagingRepositoryProfiles(warnIfMissing: Boolean = true) = { + log.info("Reading staging repository profiles...") + // Note: using /stging/profile_repositories/(profile id) is preferred to reduce the response size, + // but Sonatype API is quite slow (as of Sep 2019) so using a single request was much better. + Get(s"/staging/profile_repositories") { response => + val profileRepositoriesXML = XML.load(response.getEntity.getContent) + val repositoryProfiles = for (p <- profileRepositoriesXML \\ "stagingProfileRepository") yield { + StagingRepositoryProfile( + (p \ "profileId").text, + (p \ "profileName").text, + (p \ "type").text, + (p \ "repositoryId").text, + (p \ "description").text + ) + } + val myProfiles = repositoryProfiles.filter(_.profileName == profileName) + if (myProfiles.isEmpty && warnIfMissing) { + log.warn(s"No staging repository is found. Do publishSigned first.") + } + myProfiles + } + } + + def stagingProfiles: Seq[StagingProfile] = { + log.info("Reading staging profiles...") + Get("/staging/profiles") { response => + val profileXML = XML.load(response.getEntity.getContent) + val profiles = for (p <- profileXML \\ "stagingProfile" if (p \ "name").text == profileName) yield { + StagingProfile( + (p \ "id").text, + (p \ "name").text, + (p \ "repositoryTargetId").text + ) + } + profiles + } + } + + lazy val currentProfile = { + val profiles = stagingProfiles + if (profiles.isEmpty) { + throw new IllegalArgumentException( + s"Profile ${profileName} is not found. Check your sonatypeProfileName setting in build.sbt") + } + profiles.head + } + + private def createRequestXML(description: String) = + s"""| + | + | + | ${Utility.escape(description)} + | + | + """.stripMargin + + private def promoteRequestXML(repo: StagingRepositoryProfile) = + s"""| + | + | + | ${repo.repositoryId} + | ${currentProfile.repositoryTargetId} + | ${repo.description} + | + | + """.stripMargin + + class ExponentialBackOffRetry(initialWaitSeq: Int = 5, intervalSeq: Int = 3, maxRetries: Int = 10) { + private var numTrial = 0 + private var currentInterval = intervalSeq + + def hasNext = numTrial < maxRetries + + def nextWait = { + val interval = if (numTrial == 0) initialWaitSeq else currentInterval + currentInterval = (currentInterval * 1.5 + 0.5).toInt + numTrial += 1 + interval + } + + def doWait: Unit = { + val w = nextWait + Thread.sleep(w * 1000) + } + + } + + def createStage(description: String = "Requested by sbt-sonatype plugin"): StagingRepositoryProfile = { + val profile = currentProfile + val postURL = s"/staging/profiles/${profile.profileId}/start" + log.info(s"Creating a staging repository in profile ${profile.profileName} with a description key: ${description}") + var repo: StagingRepositoryProfile = null + val ret = Post( + postURL, + createRequestXML(description), + (in: java.io.InputStream) => { + val xml = XML.load(in) + val ids = xml \\ "data" \ "stagedRepositoryId" + if (1 != ids.size) + throw new IOException(s"Failed to create repository in profile: ${profile.profileName}") + repo = StagingRepositoryProfile( + profile.profileId, + profile.profileName, + "open", + ids.head.text, + description + ) + log.info(s"Created successfully: ${repo.repositoryId}") + } + ) + if (ret.getStatusLine.getStatusCode != HttpStatus.SC_CREATED) { + throw new IOException( + s"Failed to create repository in profile: ${profile.profileName}: ${ret.getStatusLine}" + ) + } + if (null == repo) { + throw new IOException( + s"Failed to create repository in profile: ${profile.profileName}: no stagedRepositoryId" + ) + } + repo + } + + def closeStage(repo: StagingRepositoryProfile): StagingRepositoryProfile = { + var toContinue = true + if (repo.isClosed || repo.isReleased) { + log.info(s"Repository ${repo.repositoryId} is already closed") + toContinue = false + } + + if (toContinue) { + // Post close request + val postURL = s"/staging/profiles/${repo.profileId}/finish" + log.info(s"Closing staging repository $repo") + val ret = Post(postURL, promoteRequestXML(repo)) + if (ret.getStatusLine.getStatusCode != HttpStatus.SC_CREATED) { + throw new IOException(s"Failed to send close operation: ${ret.getStatusLine}") + } + } + + toContinue = true + val timer = new ExponentialBackOffRetry() + while (toContinue && timer.hasNext) { + val activities = activitiesOf(repo) + monitor.report(activities) + activities.filter(_.name == "close").lastOption match { + case Some(activity) => + if (activity.isCloseSucceeded(repo.repositoryId)) { + toContinue = false + log.info("Closed successfully") + } else if (activity.containsError) { + log.error("Failed to close the repository") + activity.reportFailure(log) + throw new Exception("Failed to close the repository") + } else { + // Activity log exists, but the close phase is not yet terminated + log.debug("Close process is in progress ...") + timer.doWait + } + case None => + timer.doWait + } + } + if (toContinue) + throw new IOException("Timed out") + + repo.toClosed + } + + def dropStage(repo: StagingRepositoryProfile): StagingRepositoryProfile = { + val postURL = s"/staging/profiles/${repo.profileId}/drop" + log.info(s"Dropping staging repository $repo") + val ret = Post(postURL, promoteRequestXML(repo)) + if (ret.getStatusLine.getStatusCode != HttpStatus.SC_CREATED) { + throw new IOException(s"Failed to drop ${repo.repositoryId}: ${ret.getStatusLine}") + } + log.info(s"Dropped successfully: ${repo.repositoryId}") + repo.toDropped + } + + def promoteStage(repo: StagingRepositoryProfile): StagingRepositoryProfile = { + var toContinue = true + if (repo.isReleased) { + log.info(s"Repository ${repo.repositoryId} is already released") + toContinue = false + } + + if (toContinue) { + // Post promote(release) request + val postURL = s"/staging/profiles/${repo.profileId}/promote" + log.info(s"Promoting staging repository $repo") + val ret = Post(postURL, promoteRequestXML(repo)) + if (ret.getStatusLine.getStatusCode != HttpStatus.SC_CREATED) { + log.error(s"${ret.getStatusLine}") + for (errorLine <- Source.fromInputStream(ret.getEntity.getContent).getLines()) { + log.error(errorLine) + } + throw new Exception("Failed to promote the repository") + } + } + + toContinue = true + var result: StagingRepositoryProfile = null + val timer = new ExponentialBackOffRetry() + while (toContinue && timer.hasNext) { + val activities = activitiesOf(repo) + monitor.report(activities) + activities.filter(_.name == "release").lastOption match { + case Some(activity) => + if (activity.isReleaseSucceeded(repo.repositoryId)) { + log.info("Promoted successfully") + + // Drop after release + result = dropStage(repo.toReleased) + toContinue = false + } else if (activity.containsError) { + log.error("Failed to promote the repository") + activity.reportFailure(log) + throw new Exception("Failed to promote the repository") + } else { + log.debug("Release process is in progress ...") + timer.doWait + } + case None => + timer.doWait + } + } + if (toContinue) + throw new IOException("Timed out") + require(null != result) + result + } + + def stagingRepositoryInfo(repositoryId: String) = { + log.info(s"Seaching for repository $repositoryId ...") + val ret = Get(s"/staging/repository/$repositoryId") { response => + XML.load(response.getEntity.getContent) + } + ret + } + + def closeAndPromote(repo: StagingRepositoryProfile): StagingRepositoryProfile = { + if (repo.isReleased) { + dropStage(repo) + } else { + val closed = closeStage(repo) + promoteStage(closed) + } + } + + def activities: Seq[(StagingRepositoryProfile, Seq[StagingActivity])] = { + for (r <- stagingRepositoryProfiles()) yield r -> activitiesOf(r) + } + + def activitiesOf(r: StagingRepositoryProfile): Seq[StagingActivity] = { + log.debug(s"Checking activity logs of ${r.repositoryId} ...") + val a = Get(s"/staging/repository/${r.repositoryId}/activity") { response => + val xml = XML.load(response.getEntity.getContent) + for (sa <- xml \\ "stagingActivity") yield { + val ae = for (event <- sa \ "events" \ "stagingActivityEvent") yield { + val props = for (prop <- event \ "properties" \ "stagingProperty") yield { + (prop \ "name").text -> (prop \ "value").text + } + ActivityEvent((event \ "timestamp").text, (event \ "name").text, (event \ "severity").text, props.toMap) + } + StagingActivity((sa \ "name").text, (sa \ "started").text, (sa \ "stopped").text, ae.toSeq) + } + } + a + } +} + +object NexusRESTService { + + /** + * Switches of a Sonatype command to use + */ + sealed trait CommandType { + def errNotFound: String + } + case object Close extends CommandType { + def errNotFound = "No open repository is found. Run publishSigned first" + } + case object Promote extends CommandType { + def errNotFound = "No closed repository is found. Run publishSigned and close commands" + } + case object Drop extends CommandType { + def errNotFound = "No staging repository is found. Run publishSigned first" + } + case object CloseAndPromote extends CommandType { + def errNotFound = "No staging repository is found. Run publishSigned first" + } + + /** + * Staging repository profile has an id of deployed artifact and the current staging state. + * @param profileId + * @param profileName + * @param stagingType + * @param repositoryId + * @param description + */ + case class StagingRepositoryProfile( + profileId: String, + profileName: String, + stagingType: String, + repositoryId: String, + description: String + ) { + override def toString = + s"[$repositoryId] status:$stagingType, profile:$profileName($profileId) description: $description" + def isOpen = stagingType == "open" + def isClosed = stagingType == "closed" + def isReleased = stagingType == "released" + + def toClosed = copy(stagingType = "closed") + def toDropped = copy(stagingType = "dropped") + def toReleased = copy(stagingType = "released") + } + + /** + * Staging profile is the information associated to a Sonatype account. + * @param profileId + * @param profileName + * @param repositoryTargetId + */ + case class StagingProfile(profileId: String, profileName: String, repositoryTargetId: String) + + /** + * Staging activity is an action to the staged repository + * @param name activity name, e.g. open, close, promote, etc. + * @param started + * @param stopped + * @param events + */ + case class StagingActivity(name: String, started: String, stopped: String, events: Seq[ActivityEvent]) { + override def toString = { + val b = Seq.newBuilder[String] + b += s"-activity -- name:$name, started:$started, stopped:$stopped" + for (e <- events) + b += s" ${e.toString}" + b.result.mkString("\n") + } + + def activityLog = s"Activity $name started:$started, stopped:$stopped" + + def log(log: Logger): Unit = { + log.info(activityLog) + val hasError = containsError + for (e <- suppressEvaluateLog) { + e.log(log, hasError) + } + } + + def suppressEvaluateLog = { + val in = events.toIndexedSeq + var cursor = 0 + val b = Seq.newBuilder[ActivityEvent] + while (cursor < in.size) { + val current = in(cursor) + if (cursor < in.size - 1) { + val next = in(cursor + 1) + if (current.name == "ruleEvaluate" && current.ruleType == next.ruleType) { + // skip + } else { + b += current + } + } + cursor += 1 + } + b.result + } + + def containsError = events.exists(_.severity != "0") + + def reportFailure(log: Logger): Unit = { + log.error(activityLog) + val failureReport = suppressEvaluateLog.filter(_.isFailure) + for (e <- failureReport) { + e.log(log, useErrorLog = true) + } + } + + def isReleaseSucceeded(repositoryId: String): Boolean = { + events + .find(_.name == "repositoryReleased") + .exists(_.property.getOrElse("id", "") == repositoryId) + } + + def isCloseSucceeded(repositoryId: String): Boolean = { + events + .find(_.name == "repositoryClosed") + .exists(_.property.getOrElse("id", "") == repositoryId) + } + + } + + /** + * ActivityEvent is an evaluation result (e.g., checksum, signature check, etc.) of a rule defined in a StagingActivity ruleset + * @param timestamp + * @param name + * @param severity + * @param property + */ + case class ActivityEvent(timestamp: String, name: String, severity: String, property: Map[String, String]) { + def ruleType: String = property.getOrElse("typeId", "other") + def isFailure = name == "ruleFailed" + + override def toString = + s"-event -- timestamp:$timestamp, name:$name, severity:$severity, ${property.map(p => s"${p._1}:${p._2}").mkString(", ")}" + + def log(s: Logger, useErrorLog: Boolean = false): Unit = { + val props = { + val front = + if (property.contains("typeId")) + Seq(property("typeId")) + else + Seq.empty + front ++ property.filter(_._1 != "typeId").map(p => s"${p._1}:${p._2}") + } + val messageLine = props.mkString(", ") + val name_s = name.replaceAll("rule(s)?", "") + val message = f"$name_s%10s: $messageLine" + if (useErrorLog) + s.error(message) + else + s.info(message) + } + } + + class ActivityMonitor(s: Logger) { + var reportedActivities = Set.empty[String] + var reportedEvents = Set.empty[ActivityEvent] + + def report(stagingActivities: Seq[StagingActivity]) = { + for (sa <- stagingActivities) { + if (!reportedActivities.contains(sa.started)) { + s.info(sa.activityLog) + reportedActivities += sa.started + } + for (ae <- sa.events if !reportedEvents.contains(ae)) { + ae.log(s, useErrorLog = false) + reportedEvents += ae + } + } + } + } + +} diff --git a/src/main/scala/xerial/sbt/Sonatype.scala b/src/main/scala/xerial/sbt/Sonatype.scala index b026e11a..25971b22 100755 --- a/src/main/scala/xerial/sbt/Sonatype.scala +++ b/src/main/scala/xerial/sbt/Sonatype.scala @@ -7,47 +7,41 @@ package xerial.sbt +import sbt.Keys._ import sbt._ -import Keys._ -import org.apache.http.auth.{AuthScope, UsernamePasswordCredentials} -import org.apache.http.impl.client.{DefaultHttpClient, BasicCredentialsProvider} -import org.apache.http.client.methods.{HttpPost, HttpGet} -import sbt.plugins.JvmPlugin -import scala.xml.{Utility, XML} -import org.apache.http.client.HttpClient -import org.apache.http.{HttpStatus, HttpResponse} -import org.apache.http.entity.StringEntity -import scala.io.Source -import java.io.IOException +import xerial.sbt.NexusRESTService._ + +import scala.concurrent.duration.Duration +import scala.concurrent.{Await, ExecutionContext, Future} /** * Plugin for automating release processes at Sonatype Nexus - * @author Taro L. Saito */ object Sonatype extends AutoPlugin { trait SonatypeKeys { - val sonatypeRepository = settingKey[String]("Sonatype repository URL: e.g. https://oss.sonatype.org/service/local") - val sonatypeProfileName = settingKey[String]("Profile name at Sonatype: e.g. org.xerial") - val sonatypeCredentialHost = settingKey[String]("Credential host. Default is oss.sonatype.org") - val sonatypeDefaultResolver = settingKey[Resolver]("Default Sonatype Resolver") - val sonatypePublishTo = settingKey[Option[Resolver]]("Default Sonatype publishTo target") - val sonatypeStagingRepositoryProfile = settingKey[StagingRepositoryProfile]("Stating repository profile") - val sonatypeProjectHosting = settingKey[Option[ProjectHosting]]("Shortcut to fill in required Maven Central information") + val sonatypeRepository = settingKey[String]("Sonatype repository URL: e.g. https://oss.sonatype.org/service/local") + val sonatypeProfileName = settingKey[String]("Profile name at Sonatype: e.g. org.xerial") + val sonatypeCredentialHost = settingKey[String]("Credential host. Default is oss.sonatype.org") + val sonatypeDefaultResolver = settingKey[Resolver]("Default Sonatype Resolver") + val sonatypePublishTo = settingKey[Option[Resolver]]("Default Sonatype publishTo target") + val sonatypeTargetRepositoryProfile = settingKey[StagingRepositoryProfile]("Stating repository profile") + val sonatypeProjectHosting = + settingKey[Option[ProjectHosting]]("Shortcut to fill in required Maven Central information") + val sonatypeSessionName = settingKey[String]("Used for identifying a sonatype staging repository") } object SonatypeKeys extends SonatypeKeys {} object autoImport extends SonatypeKeys {} - override def trigger = allRequirements - - override def requires = JvmPlugin - + override def trigger = allRequirements override def projectSettings = sonatypeSettings import autoImport._ - import SonatypeCommand._ + import complete.DefaultParsers._ + + private implicit val ec = ExecutionContext.global lazy val sonatypeSettings = Seq[Def.Setting[_]]( sonatypeProfileName := organization.value, @@ -59,7 +53,9 @@ object Sonatype extends AutoPlugin { false }, credentials ++= { - val alreadyContainsSonatypeCredentials = credentials.value.collect { case d: DirectCredentials => d.host == sonatypeCredentialHost.value }.nonEmpty + val alreadyContainsSonatypeCredentials = credentials.value.collect { + case d: DirectCredentials => d.host == sonatypeCredentialHost.value + }.nonEmpty if (!alreadyContainsSonatypeCredentials) { val env = sys.env.get(_) (for { @@ -84,7 +80,7 @@ object Sonatype extends AutoPlugin { sonatypePublishTo := Some(sonatypeDefaultResolver.value), sonatypeDefaultResolver := { val sonatypeRepo = "https://oss.sonatype.org/" - val profileM = sonatypeStagingRepositoryProfile.?.value + val profileM = sonatypeTargetRepositoryProfile.?.value val staged = profileM.map { stagingRepoProfile => "releases" at sonatypeRepo + @@ -97,231 +93,159 @@ object Sonatype extends AutoPlugin { Opts.resolver.sonatypeStaging }) }, + sonatypeSessionName := s"[sbt-sonatype] ${name.value} ${version.value}", commands ++= Seq( - sonatypeList, + sonatypePrepare, sonatypeOpen, sonatypeClose, sonatypePromote, sonatypeDrop, - sonatypeDropAll, sonatypeRelease, + sonatypeClean, sonatypeReleaseAll, + sonatypeDropAll, sonatypeLog, sonatypeStagingRepositoryProfiles, sonatypeStagingProfiles ) ) - case class ProjectHosting( - domain: String, - user: String, - fullName: Option[String], - email: String, - repository: String - ) { - def homepage = s"https://$domain/$user/$repository" - def scmUrl = s"git@$domain:$user/$repository.git" - def scmInfo = ScmInfo(url(homepage), scmUrl) - def developer = Developer(user, fullName.getOrElse(user), email, url(s"https://$domain/$user")) + private val sonatypePrepare = newCommand( + "sonatypePrepare", + "Clean (if exists) and create a staging repository for releasing the current version, then update publishTo") { + state: State => + val extracted = Project.extract(state) + val descriptionKey = extracted.get(sonatypeSessionName) + state.log.info(s"Preparing a new staging repository for ${descriptionKey}") + val rest: NexusRESTService = getNexusRestService(state) + // Drop a previous staging repository if exists + val dropTask = Future.apply(rest.dropIfExistsByKey(descriptionKey)) + // Create a new one + val createTask = Future.apply(rest.createStage(descriptionKey)) + // Run two tasks in parallel + val merged = dropTask.zip(createTask) + val (droppedRepo, createdRepo) = Await.result(merged, Duration.Inf) + updatePublishSettings(state, createdRepo) } - object GitHubHosting { - private val domain = "github.com" - def apply(user: String, repository: String, email: String) = ProjectHosting(domain, user, None, email, repository) - def apply(user: String, repository: String, fullName: String, email: String) = ProjectHosting(domain, user, Some(fullName), email, repository) - } - - object GitLabHosting { - private val domain = "gitlab.com" - def apply(user: String, repository: String, email: String) = ProjectHosting(domain, user, None, email, repository) - def apply(user: String, repository: String, fullName: String, email: String) = ProjectHosting(domain, user, Some(fullName), email, repository) + private val sonatypeOpen = newCommand( + "sonatypeOpen", + "Open (or create if not exists) to a staging repository for the current version, then update publishTo") { + state: State => + val rest = getNexusRestService(state) + // Re-open or create a staging repository + val descriptionKey = Project.extract(state).get(sonatypeSessionName) + val repo = rest.openOrCreateByKey(descriptionKey) + updatePublishSettings(state, repo) } - // aliases - @deprecated("Use GitHubHosting (capital H) instead", "2.2") - val GithubHosting = GitHubHosting - @deprecated("Use GitLabHosting (capital L) instead", "2.2") - val GitlabHosting = GitLabHosting - - object SonatypeCommand { - import complete.DefaultParsers._ - - /** - * Parsing repository id argument - */ - private def getCredentials(extracted: Extracted, state: State) = { - val (nextState, credential) = extracted.runTask(credentials, state) - credential - } - - private def getNexusRestService(state: State, profileName: Option[String] = None) = { - val extracted = Project.extract(state) - new NexusRESTService( - state.log, - extracted.get(sonatypeRepository), - profileName.getOrElse(extracted.get(sonatypeProfileName)), - getCredentials(extracted, state), - extracted.get(sonatypeCredentialHost) + private def updatePublishSettings(state: State, repo: StagingRepositoryProfile): State = { + val extracted = Project.extract(state) + // accumulate changes for settings for current project and all aggregates + state.log.info(s"Updating sonatypePublishTo settings...") + val newSettings: Seq[Def.Setting[_]] = extracted.currentProject.referenced.flatMap { ref => + Seq( + ref / sonatypeTargetRepositoryProfile := repo ) - } - - val sonatypeList: Command = Command.command("sonatypeList", "List staging repositories", "List published repository IDs") { state => - val rest = getNexusRestService(state) - val profiles = rest.stagingProfiles - val log = state.log - if (profiles.isEmpty) { - log.warn(s"No staging profile is found for ${rest.profileName}") - state.fail - } else { - log.info(s"Staging profiles (profileName:${rest.profileName}):") - log.info(profiles.mkString("\n")) - state - } - } - - private val repositoryIdParser: complete.Parser[Option[String]] = - (Space ~> token(StringBasic, "(repositoryId)")).?.!!!("invalid input. please input repository name") - - private val sonatypeProfileParser: complete.Parser[Option[String]] = - (Space ~> token(StringBasic, "(sonatypeProfile)")).?.!!!("invalid input. please input sonatypeProfile (e.g., org.xerial)") - - private val sonatypeProfileDescriptionParser: complete.Parser[Either[String, (String, String)]] = - Space ~> - (token(StringBasic, "description") || - (token(StringBasic <~ Space, "sonatypeProfile") ~ token(StringBasic, "description"))) - - private def commandWithRepositoryId(name: String, briefHelp: String) = Command(name, (name, briefHelp), briefHelp)(_ => repositoryIdParser)(_) - - private def commandWithSonatypeProfile(name: String, briefHelp: String) = Command(name, (name, briefHelp), briefHelp)(_ => sonatypeProfileParser)(_) - - private def commandWithSonatypeProfileDescription(name: String, briefHelp: String) = Command(name, (name, briefHelp), briefHelp)(_ => sonatypeProfileDescriptionParser)(_) - - def getUpdatedPublishTo(profileName: String, current: Option[Option[Resolver]]): Seq[Setting[_]] = { - val result = for { - currentIfSet <- current - currentPublishTo <- currentIfSet - if profileName == currentPublishTo.name - setting = publishTo := None - } yield setting - result.toSeq - } - - val sonatypeOpen: Command = commandWithSonatypeProfileDescription("sonatypeOpen", "Create a staging repository and set publishTo") { (state, profileNameDescription) => - val (profileName: Option[String], profileDescription: String) = profileNameDescription match { - case Left(d) => - (None, d) - case Right((n, d)) => - (Some(n), d) - } - val rest = getNexusRestService(state, profileName) - val repo = rest.createStage(profileDescription) - val path = "/staging/deployByRepositoryId/" + repo.repositoryId - val extracted = Project.extract(state) + } ++ Seq( + sonatypeTargetRepositoryProfile := repo + ) - // accumulate changes for settings for current project and all aggregates - val newSettings : Seq[Def.Setting[_]] = extracted.currentProject.referenced.flatMap { ref => - Seq( - ref / sonatypeStagingRepositoryProfile := repo, - ref / publishTo := Some(sonatypeDefaultResolver.value) - ) - } ++ Seq( - sonatypeStagingRepositoryProfile := repo, - publishTo := Some(sonatypeDefaultResolver.value) - ) + val next = extracted.appendWithoutSession(newSettings, state) + next + } - val next = extracted.appendWithSession(newSettings, state) - next - } + private val sonatypeClose = commandWithRepositoryId("sonatypeClose", "") { (state: State, arg: Option[String]) => + val extracted = Project.extract(state) + val repoID = arg.orElse(extracted.getOpt(sonatypeTargetRepositoryProfile).map(_.repositoryId)) + val rest = getNexusRestService(state) + val repo1 = rest.findTargetRepository(Close, repoID) + val repo2 = rest.closeStage(repo1) + extracted.appendWithoutSession(Seq(sonatypeTargetRepositoryProfile := repo2), state) + } - val sonatypeClose: Command = commandWithRepositoryId("sonatypeClose", "Close a stage and clear publishTo if it was set by sonatypeOpen") { (state, parsed) => - val rest = getNexusRestService(state) + private val sonatypePromote = commandWithRepositoryId("sonatypePromote", "Promote a staging repository") { + (state: State, arg: Option[String]) => val extracted = Project.extract(state) - val currentRepoID = for { - repo <- extracted.getOpt(sonatypeStagingRepositoryProfile) - } yield repo.repositoryId - val repoID = parsed.orElse(currentRepoID) - val repo1 = rest.findTargetRepository(Close, repoID) - val repo2 = rest.closeStage(repo1) - val next = extracted.append(Seq(sonatypeStagingRepositoryProfile := repo2), state) - next - } - - val sonatypePromote: Command = commandWithRepositoryId("sonatypePromote", "Promote a staged repository") { (state, parsed) => + val repoID = arg.orElse(extracted.getOpt(sonatypeTargetRepositoryProfile).map(_.repositoryId)) val rest = getNexusRestService(state) - val extracted = Project.extract(state) - val currentRepoID = for { - repo <- extracted.getOpt(sonatypeStagingRepositoryProfile) - } yield repo.repositoryId - val repoID = parsed.orElse(currentRepoID) - val repo1 = rest.findTargetRepository(Promote, repoID) - val repo2 = rest.promoteStage(repo1) - val next = extracted.append(Seq(sonatypeStagingRepositoryProfile := repo2), state) - next - } + val repo1 = rest.findTargetRepository(Promote, repoID) + val repo2 = rest.promoteStage(repo1) + extracted.appendWithoutSession(Seq(sonatypeTargetRepositoryProfile := repo2), state) + } - val sonatypeDrop: Command = commandWithRepositoryId("sonatypeDrop", "Drop a staging repository") { (state, parsed) => - val rest = getNexusRestService(state) + private val sonatypeDrop = commandWithRepositoryId("sonatypeDrop", "Drop a staging repository") { + (state: State, arg: Option[String]) => val extracted = Project.extract(state) - val currentRepoID = for { - repo <- extracted.getOpt(sonatypeStagingRepositoryProfile) - } yield repo.repositoryId - val repoID = parsed.orElse(currentRepoID) - val repo1 = rest.findTargetRepository(Drop, repoID) - val repo2 = rest.dropStage(repo1) - val next = extracted.append(Seq(sonatypeStagingRepositoryProfile := repo2), state) - next - } - - val sonatypeRelease: Command = commandWithRepositoryId("sonatypeRelease", "Publish with sonatypeClose and sonatypePromote") { (state, parsed) => + val repoID = arg.orElse(extracted.getOpt(sonatypeTargetRepositoryProfile).map(_.repositoryId)) val rest = getNexusRestService(state) - val extracted = Project.extract(state) - val currentRepoID = for { - repo <- extracted.getOpt(sonatypeStagingRepositoryProfile) - } yield repo.repositoryId - val repoID = parsed.orElse(currentRepoID) - val repo1 = rest.findTargetRepository(CloseAndPromote, repoID) - val repo2 = rest.closeAndPromote(repo1) - val next = extracted.append(Seq(sonatypeStagingRepositoryProfile := repo2), state) - next - } + val repo1 = rest.findTargetRepository(Drop, repoID) + val repo2 = rest.dropStage(repo1) + extracted.appendWithoutSession(Seq(sonatypeTargetRepositoryProfile := repo2), state) + } - val sonatypeReleaseAll: Command = commandWithSonatypeProfile("sonatypeReleaseAll", "Publish all staging repositories to Maven central") { (state, profileName) => - val rest = getNexusRestService(state, profileName) - for { - repo <- rest.stagingRepositoryProfiles - _ = rest.closeAndPromote(repo) - } () + private val sonatypeRelease = + commandWithRepositoryId("sonatypeRelease", "Publish with sonatypeClose and sonatypePromote") { + (state: State, arg: Option[String]) => + val extracted = Project.extract(state) + val repoID = arg.orElse(extracted.getOpt(sonatypeTargetRepositoryProfile).map(_.repositoryId)) + val rest = getNexusRestService(state) + val repo1 = rest.findTargetRepository(CloseAndPromote, repoID) + val repo2 = rest.closeAndPromote(repo1) + extracted.appendWithoutSession(Seq(sonatypeTargetRepositoryProfile := repo2), state) + } + + private val sonatypeClean = + newCommand("sonatypeClean", "Clean a staging repository for the current version if it exists") { state: State => + val extracted = Project.extract(state) + val rest = getNexusRestService(state) + val descriptionKey = extracted.get(sonatypeSessionName) + rest.dropIfExistsByKey(descriptionKey) state } - val sonatypeDropAll: Command = commandWithSonatypeProfile("sonatypeDropAll", "Drop all staging repositories") { - (state, profileName) => - val rest = getNexusRestService(state, profileName) - for { - repo <- rest.stagingRepositoryProfiles - _ = rest.dropStage(repo) - }() + private val sonatypeReleaseAll = + commandWithRepositoryId("sonatypeReleaseAll", "Publish all staging repositories to Maven central") { + (state: State, arg: Option[String]) => + val rest = getNexusRestService(state, arg) + val tasks = rest.stagingRepositoryProfiles().map { repo => + Future.apply(rest.closeAndPromote(repo)) + } + val merged = Future.sequence(tasks) + Await.result(merged, Duration.Inf) state } - val sonatypeLog: Command = Command.command("sonatypeLog", "Show repository activities", "Show staging activity logs at Sonatype") { state => - val rest = getNexusRestService(state) - val alist = rest.activities - val log = state.log - if (alist.isEmpty) - log.warn("No staging log is found") - for ((repo, activities) <- alist) { - log.info(s"Staging activities of $repo:") - for (a <- activities) { - a.log(log) - } + private val sonatypeDropAll = commandWithRepositoryId("sonatypeDropAll", "Drop all staging repositories") { + (state: State, arg: Option[String]) => + val rest = getNexusRestService(state, arg) + val dropTasks = rest.stagingRepositoryProfiles().map { repo => + Future.apply(rest.dropStage(repo)) } + val merged = Future.sequence(dropTasks) + Await.result(merged, Duration.Inf) state + } + + private val sonatypeLog = newCommand("sonatypeLog", "Show staging activity logs at Sonatype") { state: State => + val rest = getNexusRestService(state) + val alist = rest.activities + val log = state.log + if (alist.isEmpty) + log.warn("No staging log is found") + for ((repo, activities) <- alist) { + log.info(s"Staging activities of $repo:") + for (a <- activities) { + a.log(log) + } } + state + } - val sonatypeStagingRepositoryProfiles = Command.command("sonatypeStagingRepositoryProfiles") { state => + private val sonatypeStagingRepositoryProfiles = + newCommand("sonatypeStagingRepositoryProfiles", "Show the list of staging repository profiles") { state: State => val rest = getNexusRestService(state) - val repos = rest.stagingRepositoryProfiles + val repos = rest.stagingRepositoryProfiles() val log = state.log if (repos.isEmpty) log.warn(s"No staging repository is found for ${rest.profileName}") @@ -332,7 +256,8 @@ object Sonatype extends AutoPlugin { state } - val sonatypeStagingProfiles = Command.command("sonatypeStagingProfiles") { state => + private val sonatypeStagingProfiles = newCommand("sonatypeStagingProfiles", "Show the list of staging profiles") { + state: State => val rest = getNexusRestService(state) val profiles = rest.stagingProfiles val log = state.log @@ -343,561 +268,71 @@ object Sonatype extends AutoPlugin { log.info(profiles.mkString("\n")) } state - } } - /** - * Switches of a Sonatype command to use - */ - private sealed trait CommandType { - def errNotFound: String - } - private case object Close extends CommandType { - def errNotFound = "No open repository is found. Run publishSigned first" - } - private case object Promote extends CommandType { - def errNotFound = "No closed repository is found. Run publishSigned and close commands" - } - private case object Drop extends CommandType { - def errNotFound = "No staging repository is found. Run publishSigned first" - } - private case object CloseAndPromote extends CommandType { - def errNotFound = "No staging repository is found. Run publishSigned first" + case class ProjectHosting( + domain: String, + user: String, + fullName: Option[String], + email: String, + repository: String + ) { + def homepage = s"https://$domain/$user/$repository" + def scmUrl = s"git@$domain:$user/$repository.git" + def scmInfo = ScmInfo(url(homepage), scmUrl) + def developer = Developer(user, fullName.getOrElse(user), email, url(s"https://$domain/$user")) } - /** - * Staging repository profile has an id of deployed artifact and the current staging state. - * @param profileId - * @param profileName - * @param stagingType - * @param repositoryId - * @param description - */ - case class StagingRepositoryProfile(profileId: String, profileName: String, stagingType: String, repositoryId: String, description: String) { - override def toString = s"[$repositoryId] status:$stagingType, profile:$profileName($profileId) description: $description" - def isOpen = stagingType == "open" - def isClosed = stagingType == "closed" - def isReleased = stagingType == "released" - - def toClosed = copy(stagingType = "closed") - def toDropped = copy(stagingType = "dropped") - def toReleased = copy(stagingType = "released") + object GitHubHosting { + private val domain = "github.com" + def apply(user: String, repository: String, email: String) = ProjectHosting(domain, user, None, email, repository) + def apply(user: String, repository: String, fullName: String, email: String) = + ProjectHosting(domain, user, Some(fullName), email, repository) } - /** - * Staging profile is the information associated to a Sonatype account. - * @param profileId - * @param profileName - * @param repositoryTargetId - */ - case class StagingProfile(profileId: String, profileName: String, repositoryTargetId: String) - - /** - * Staging activity is an action to the staged repository - * @param name activity name, e.g. open, close, promote, etc. - * @param started - * @param stopped - * @param events - */ - case class StagingActivity(name: String, started: String, stopped: String, events: Seq[ActivityEvent]) { - override def toString = { - val b = Seq.newBuilder[String] - b += s"-activity -- name:$name, started:$started, stopped:$stopped" - for (e <- events) - b += s" ${e.toString}" - b.result.mkString("\n") - } - - def activityLog = s"Activity $name started:$started, stopped:$stopped" - - def log(log: Logger): Unit = { - log.info(activityLog) - val hasError = containsError - for (e <- suppressEvaluateLog) { - e.log(log, hasError) - } - } - - def suppressEvaluateLog = { - val in = events.toIndexedSeq - var cursor = 0 - val b = Seq.newBuilder[ActivityEvent] - while (cursor < in.size) { - val current = in(cursor) - if (cursor < in.size - 1) { - val next = in(cursor + 1) - if (current.name == "ruleEvaluate" && current.ruleType == next.ruleType) { - // skip - } else { - b += current - } - } - cursor += 1 - } - b.result - } - - def containsError = events.exists(_.severity != "0") + object GitLabHosting { + private val domain = "gitlab.com" + def apply(user: String, repository: String, email: String) = ProjectHosting(domain, user, None, email, repository) + def apply(user: String, repository: String, fullName: String, email: String) = + ProjectHosting(domain, user, Some(fullName), email, repository) + } - def reportFailure(log: Logger): Unit = { - log.error(activityLog) - val failureReport = suppressEvaluateLog.filter(_.isFailure) - for (e <- failureReport) { - e.log(log, useErrorLog = true) - } - } + // aliases + @deprecated("Use GitHubHosting (capital H) instead", "2.2") + val GithubHosting = GitHubHosting + @deprecated("Use GitLabHosting (capital L) instead", "2.2") + val GitlabHosting = GitLabHosting - def isReleaseSucceeded(repositoryId: String): Boolean = { - events - .find(_.name == "repositoryReleased") - .exists(_.property.getOrElse("id", "") == repositoryId) - } + private val repositoryIdParser: complete.Parser[Option[String]] = + (Space ~> token(StringBasic, "(sonatype staging repository id)")).?.!!!( + "invalid input. please input a repository id") - def isCloseSucceeded(repositoryId: String): Boolean = { - events - .find(_.name == "repositoryClosed") - .exists(_.property.getOrElse("id", "") == repositoryId) - } + private val sonatypeProfileParser: complete.Parser[Option[String]] = + (Space ~> token(StringBasic, "(sonatypeProfileName)")).?.!!!( + "invalid input. please input sonatypeProfileName (e.g., org.xerial)" + ) + private def getCredentials(extracted: Extracted, state: State) = { + val (nextState, credential) = extracted.runTask(credentials, state) + credential } - /** - * ActivityEvent is an evaluation result (e.g., checksum, signature check, etc.) of a rule defined in a StagingActivity ruleset - * @param timestamp - * @param name - * @param severity - * @param property - */ - case class ActivityEvent(timestamp: String, name: String, severity: String, property: Map[String, String]) { - def ruleType: String = property.getOrElse("typeId", "other") - def isFailure = name == "ruleFailed" - - override def toString = s"-event -- timestamp:$timestamp, name:$name, severity:$severity, ${property.map(p => s"${p._1}:${p._2}").mkString(", ")}" - - def log(s: Logger, useErrorLog: Boolean = false): Unit = { - val props = { - val front = - if (property.contains("typeId")) - Seq(property("typeId")) - else - Seq.empty - front ++ property.filter(_._1 != "typeId").map(p => s"${p._1}:${p._2}") - } - val messageLine = props.mkString(", ") - val name_s = name.replaceAll("rule(s)?", "") - val message = f"$name_s%10s: $messageLine" - if (useErrorLog) - s.error(message) - else - s.info(message) - } + private def getNexusRestService(state: State, profileName: Option[String] = None) = { + val extracted = Project.extract(state) + new NexusRESTService( + state.log, + extracted.get(sonatypeRepository), + profileName.getOrElse(extracted.get(sonatypeProfileName)), + getCredentials(extracted, state), + extracted.get(sonatypeCredentialHost) + ) } - class ActivityMonitor(s: Logger) { - var reportedActivities = Set.empty[String] - var reportedEvents = Set.empty[ActivityEvent] - - def report(stagingActivities: Seq[StagingActivity]) = { - for (sa <- stagingActivities) { - if (!reportedActivities.contains(sa.started)) { - s.info(sa.activityLog) - reportedActivities += sa.started - } - for (ae <- sa.events if !reportedEvents.contains(ae)) { - ae.log(s, useErrorLog = false) - reportedEvents += ae - } - } - } + private def newCommand(name: String, briefHelp: String)(body: State => State) = { + Command.command(name, briefHelp, briefHelp)(body) } - /** - * Interface to access the REST API of Nexus - * @param log - * @param repositoryUrl - * @param profileName - * @param cred - * @param credentialHost - */ - class NexusRESTService(log: Logger, repositoryUrl: String, val profileName: String, cred: Seq[Credentials], credentialHost: String) { - - val monitor = new ActivityMonitor(log) - - def findTargetRepository(command: CommandType, arg: Option[String]): StagingRepositoryProfile = { - val repos = command match { - case Close => openRepositories - case Promote => closedRepositories - case Drop => stagingRepositoryProfiles - case CloseAndPromote => stagingRepositoryProfiles - } - if (repos.isEmpty) { - if (stagingProfiles.isEmpty) { - log.error(s"No staging profile found for $profileName") - log.error("Have you requested a staging profile and successfully published your signed artifact there?") - throw new IllegalStateException(s"No staging profile found for $profileName") - } else { - throw new IllegalStateException(command.errNotFound) - } - } - - def findSpecifiedInArg(target: String) = { - repos.find(_.repositoryId == target).getOrElse { - log.error(s"Repository $target is not found") - log.error(s"Specify one of the repository ids in:\n${repos.mkString("\n")}") - throw new IllegalArgumentException(s"Repository $target is not found") - } - } + private def commandWithRepositoryId(name: String, briefHelp: String) = + Command(name, (name, briefHelp), briefHelp)(_ => repositoryIdParser)(_) - arg.map(findSpecifiedInArg).getOrElse { - if (repos.size > 1) { - log.error(s"Multiple repositories are found:\n${repos.mkString("\n")}") - log.error(s"Specify one of the repository ids in the command line") - throw new IllegalStateException("Found multiple staging repositories") - } else { - repos.head - } - } - } - - def openRepositories = stagingRepositoryProfiles.filter(_.isOpen).sortBy(_.repositoryId) - def closedRepositories = stagingRepositoryProfiles.filter(_.isClosed).sortBy(_.repositoryId) - - private def repoBase(url: String) = if (url.endsWith("/")) url.dropRight(1) else url - private val repo = { - val url = repoBase(repositoryUrl) - log.info(s"Nexus repository URL: $url") - log.info(s"sonatypeProfileName = ${profileName}") - url - } - - def Get[U](path: String)(body: HttpResponse => U): U = { - val req = new HttpGet(s"${repo}$path") - req.addHeader("Content-Type", "application/xml") - - val retry = new ExponentialBackOffRetry(initialWaitSeq = 0) - var toContinue = true - var response: HttpResponse = null - var ret: Any = null - while (toContinue && retry.hasNext) { - withHttpClient { client => - response = client.execute(req) - log.debug(s"Status line: ${response.getStatusLine}") - response.getStatusLine.getStatusCode match { - case HttpStatus.SC_OK => - toContinue = false - ret = body(response) - case HttpStatus.SC_INTERNAL_SERVER_ERROR => - log.warn(s"Received 500 error: ${response.getStatusLine}. Retrying...") - retry.doWait - case _ => - throw new IOException(s"Failed to retrieve data from $path: ${response.getStatusLine}") - } - } - } - if (ret == null) { - throw new IOException(s"Failed to retrieve data from $path") - } - ret.asInstanceOf[U] - } - - def IgnoreEntityContent(in: java.io.InputStream): Unit = () - - def Post(path: String, bodyXML: String, contentHandler: java.io.InputStream => Unit = IgnoreEntityContent) = { - val req = new HttpPost(s"${repo}$path") - req.setEntity(new StringEntity(bodyXML)) - req.addHeader("Content-Type", "application/xml") - - val retry = new ExponentialBackOffRetry(initialWaitSeq = 0) - var response: HttpResponse = null - var toContinue = true - while (toContinue && retry.hasNext) { - withHttpClient { client => - response = client.execute(req) - response.getStatusLine.getStatusCode match { - case HttpStatus.SC_INTERNAL_SERVER_ERROR => - log.warn(s"Received 500 error: ${response.getStatusLine}. Retrying...") - retry.doWait - case _ => - log.debug(s"Status line: ${response.getStatusLine}") - toContinue = false - contentHandler(response.getEntity.getContent) - } - } - } - if (toContinue) { - throw new IOException(s"Failed to retrieve data from $path") - } - response - } - - private def withHttpClient[U](body: HttpClient => U): U = { - val credt: DirectCredentials = Credentials - .forHost(cred, credentialHost) - .getOrElse { - throw new IllegalStateException(s"No credential is found for $credentialHost. Prepare ~/.sbt/(sbt_version)/sonatype.sbt file.") - } - - val client = new DefaultHttpClient() - try { - val user = credt.userName - val passwd = credt.passwd - client.getCredentialsProvider.setCredentials( - new AuthScope(credt.host, AuthScope.ANY_PORT), - new UsernamePasswordCredentials(user, passwd) - ) - body(client) - } finally client.getConnectionManager.shutdown() - } - - def stagingRepositoryProfiles = { - log.info("Reading staging repository profiles...") - Get("/staging/profile_repositories") { response => - val profileRepositoriesXML = XML.load(response.getEntity.getContent) - val repositoryProfiles = for (p <- profileRepositoriesXML \\ "stagingProfileRepository") yield { - StagingRepositoryProfile((p \ "profileId").text, (p \ "profileName").text, (p \ "type").text, (p \ "repositoryId").text, (p \ "description").text) - } - val myProfiles = repositoryProfiles.filter(_.profileName == profileName) - if (myProfiles.isEmpty) { - log.warn(s"No staging repository is found. Do publishSigned first.") - } - myProfiles - } - } - - def stagingProfiles = { - log.info("Reading staging profiles...") - Get("/staging/profiles") { response => - val profileXML = XML.load(response.getEntity.getContent) - val profiles = for (p <- profileXML \\ "stagingProfile" if (p \ "name").text == profileName) yield { - StagingProfile( - (p \ "id").text, - (p \ "name").text, - (p \ "repositoryTargetId").text - ) - } - profiles - } - } - - lazy val currentProfile = { - val profiles = stagingProfiles - if (profiles.isEmpty) { - throw new IllegalArgumentException(s"Profile ${profileName} is not found") - } - profiles.head - } - - private def createRequestXML(description: String) = - s"""| - | - | - | ${Utility.escape(description)} - | - | - """.stripMargin - - private def promoteRequestXML(repo: StagingRepositoryProfile) = - s"""| - | - | - | ${repo.repositoryId} - | ${currentProfile.repositoryTargetId} - | ${repo.description} - | - | - """.stripMargin - - class ExponentialBackOffRetry(initialWaitSeq: Int = 5, intervalSeq: Int = 3, maxRetries: Int = 10) { - private var numTrial = 0 - private var currentInterval = intervalSeq - - def hasNext = numTrial < maxRetries - - def nextWait = { - val interval = if (numTrial == 0) initialWaitSeq else currentInterval - currentInterval = (currentInterval * 1.5 + 0.5).toInt - numTrial += 1 - interval - } - - def doWait: Unit = { - val w = nextWait - Thread.sleep(w * 1000) - } - - } - - def createStage(description: String = "Requested by sbt-sonatype plugin"): StagingRepositoryProfile = { - val postURL = s"/staging/profiles/${currentProfile.profileId}/start" - log.info(s"Creating staging repository in profile: ${currentProfile.profileName}") - var repo: StagingRepositoryProfile = null - val ret = Post( - postURL, - createRequestXML(description), - (in: java.io.InputStream) => { - val xml = XML.load(in) - val ids = xml \\ "data" \ "stagedRepositoryId" - if (1 != ids.size) - throw new IOException(s"Failed to create repository in profile: ${currentProfile.profileName}") - repo = StagingRepositoryProfile(currentProfile.profileId, currentProfile.profileName, "open", ids.head.text, description) - log.info(s"Created successfully: ${repo.repositoryId}") - } - ) - if (ret.getStatusLine.getStatusCode != HttpStatus.SC_CREATED) { - throw new IOException(s"Failed to create repository in profile: ${currentProfile.profileName}: ${ret.getStatusLine}") - } - if (null == repo) { - throw new IOException(s"Failed to create repository in profile: ${currentProfile.profileName}: no stagedRepositoryId") - } - repo - } - - def closeStage(repo: StagingRepositoryProfile): StagingRepositoryProfile = { - var toContinue = true - if (repo.isClosed || repo.isReleased) { - log.info(s"Repository ${repo.repositoryId} is already closed") - toContinue = false - } - - if (toContinue) { - // Post close request - val postURL = s"/staging/profiles/${currentProfile.profileId}/finish" - log.info(s"Closing staging repository $repo") - val ret = Post(postURL, promoteRequestXML(repo)) - if (ret.getStatusLine.getStatusCode != HttpStatus.SC_CREATED) { - throw new IOException(s"Failed to send close operation: ${ret.getStatusLine}") - } - } - - toContinue = true - val timer = new ExponentialBackOffRetry() - while (toContinue && timer.hasNext) { - val activities = activitiesOf(repo) - monitor.report(activities) - activities.filter(_.name == "close").lastOption match { - case Some(activity) => - if (activity.isCloseSucceeded(repo.repositoryId)) { - toContinue = false - log.info("Closed successfully") - } else if (activity.containsError) { - log.error("Failed to close the repository") - activity.reportFailure(log) - throw new Exception("Failed to close the repository") - } else { - // Activity log exists, but the close phase is not yet terminated - log.debug("Close process is in progress ...") - timer.doWait - } - case None => - timer.doWait - } - } - if (toContinue) - throw new IOException("Timed out") - - repo.toClosed - } - - def dropStage(repo: StagingRepositoryProfile): StagingRepositoryProfile = { - val postURL = s"/staging/profiles/${currentProfile.profileId}/drop" - log.info(s"Dropping staging repository $repo") - val ret = Post(postURL, promoteRequestXML(repo)) - if (ret.getStatusLine.getStatusCode != HttpStatus.SC_CREATED) { - throw new IOException(s"Failed to drop ${repo.repositoryId}: ${ret.getStatusLine}") - } - log.info(s"Dropped successfully: ${repo.repositoryId}") - repo.toDropped - } - - def promoteStage(repo: StagingRepositoryProfile): StagingRepositoryProfile = { - var toContinue = true - if (repo.isReleased) { - log.info(s"Repository ${repo.repositoryId} is already released") - toContinue = false - } - - if (toContinue) { - // Post promote(release) request - val postURL = s"/staging/profiles/${currentProfile.profileId}/promote" - log.info(s"Promoting staging repository $repo") - val ret = Post(postURL, promoteRequestXML(repo)) - if (ret.getStatusLine.getStatusCode != HttpStatus.SC_CREATED) { - log.error(s"${ret.getStatusLine}") - for (errorLine <- Source.fromInputStream(ret.getEntity.getContent).getLines()) { - log.error(errorLine) - } - throw new Exception("Failed to promote the repository") - } - } - - toContinue = true - var result: StagingRepositoryProfile = null - val timer = new ExponentialBackOffRetry() - while (toContinue && timer.hasNext) { - val activities = activitiesOf(repo) - monitor.report(activities) - activities.filter(_.name == "release").lastOption match { - case Some(activity) => - if (activity.isReleaseSucceeded(repo.repositoryId)) { - log.info("Promoted successfully") - - // Drop after release - result = dropStage(repo.toReleased) - toContinue = false - } else if (activity.containsError) { - log.error("Failed to promote the repository") - activity.reportFailure(log) - throw new Exception("Failed to promote the repository") - } else { - log.debug("Release process is in progress ...") - timer.doWait - } - case None => - timer.doWait - } - } - if (toContinue) - throw new IOException("Timed out") - require(null != result) - result - } - - def stagingRepositoryInfo(repositoryId: String) = { - log.info(s"Seaching for repository $repositoryId ...") - val ret = Get(s"/staging/repository/$repositoryId") { response => - XML.load(response.getEntity.getContent) - } - ret - } - - def closeAndPromote(repo: StagingRepositoryProfile): StagingRepositoryProfile = { - if (repo.isReleased) { - dropStage(repo) - } else { - val closed = closeStage(repo) - promoteStage(closed) - } - } - - def activities: Seq[(StagingRepositoryProfile, Seq[StagingActivity])] = { - for (r <- stagingRepositoryProfiles) yield r -> activitiesOf(r) - } - - def activitiesOf(r: StagingRepositoryProfile): Seq[StagingActivity] = { - log.debug(s"Checking activity logs of ${r.repositoryId} ...") - val a = Get(s"/staging/repository/${r.repositoryId}/activity") { response => - val xml = XML.load(response.getEntity.getContent) - for (sa <- xml \\ "stagingActivity") yield { - val ae = for (event <- sa \ "events" \ "stagingActivityEvent") yield { - val props = for (prop <- event \ "properties" \ "stagingProperty") yield { - (prop \ "name").text -> (prop \ "value").text - } - ActivityEvent((event \ "timestamp").text, (event \ "name").text, (event \ "severity").text, props.toMap) - } - StagingActivity((sa \ "name").text, (sa \ "started").text, (sa \ "stopped").text, ae.toSeq) - } - } - a - } - } } diff --git a/src/sbt-test/sbt-sonatype/example/build.sbt b/src/sbt-test/sbt-sonatype/example/build.sbt index b12672eb..e93a8cb6 100644 --- a/src/sbt-test/sbt-sonatype/example/build.sbt +++ b/src/sbt-test/sbt-sonatype/example/build.sbt @@ -1,27 +1,16 @@ organization := "org.xerial.example" - sonatypeProfileName := "org.xerial" - publishMavenStyle := true - licenses := Seq("APL2" -> url("http://www.apache.org/licenses/LICENSE-2.0.txt")) - homepage := Some(url("https://github.com/xerial/sbt-sonatype")) - scmInfo := Some( ScmInfo( url("https://github.com/xerial/sbt-sonatype"), "scm:git@github.com:xerial/sbt-sonatype.git" ) ) - developers := List( Developer(id = "leo", name = "Taro L. Saito", email = "leo@xerial.org", url = url("http://xerial.org/leo")) ) +publishTo := sonatypePublishTo.value -publishTo := Some( - if (isSnapshot.value) - Opts.resolver.sonatypeSnapshots - else - Opts.resolver.sonatypeStaging -) diff --git a/version.sbt b/version.sbt index 67a5d231..99cfbd6c 100755 --- a/version.sbt +++ b/version.sbt @@ -1 +1 @@ -version in ThisBuild := "2.7-SNAPSHOT" +version in ThisBuild := "3.0-SNAPSHOT"