summaryrefslogtreecommitdiff
path: root/scalaplugin/src
diff options
context:
space:
mode:
authorLi Haoyi <haoyi.sg@gmail.com>2017-12-20 08:04:18 -0800
committerLi Haoyi <haoyi.sg@gmail.com>2017-12-20 08:04:18 -0800
commitd288158e746a5350bb104f0b57abbe8f83485845 (patch)
tree9bc5a761b6db1c962262c4bd73705671f4f15081 /scalaplugin/src
parent5f200e4a1037c2ce477096a8da58561e86a58f30 (diff)
parent9d19d740c5b387704e08fb89412b8318549a4fc5 (diff)
downloadmill-d288158e746a5350bb104f0b57abbe8f83485845.tar.gz
mill-d288158e746a5350bb104f0b57abbe8f83485845.tar.bz2
mill-d288158e746a5350bb104f0b57abbe8f83485845.zip
Merge branch 'master' of github.com:lihaoyi/mill
Diffstat (limited to 'scalaplugin/src')
-rw-r--r--scalaplugin/src/main/scala/mill/scalaplugin/Lib.scala39
-rw-r--r--scalaplugin/src/main/scala/mill/scalaplugin/ScalaModule.scala115
-rw-r--r--scalaplugin/src/main/scala/mill/scalaplugin/publish/Ivy.scala53
-rw-r--r--scalaplugin/src/main/scala/mill/scalaplugin/publish/JsonFormatters.scala11
-rw-r--r--scalaplugin/src/main/scala/mill/scalaplugin/publish/LocalPublisher.scala33
-rw-r--r--scalaplugin/src/main/scala/mill/scalaplugin/publish/Pom.scala88
-rw-r--r--scalaplugin/src/main/scala/mill/scalaplugin/publish/SonatypeHttpApi.scala130
-rw-r--r--scalaplugin/src/main/scala/mill/scalaplugin/publish/SonatypePublisher.scala148
-rw-r--r--scalaplugin/src/main/scala/mill/scalaplugin/publish/package.scala3
-rw-r--r--scalaplugin/src/main/scala/mill/scalaplugin/publish/settings.scala70
-rw-r--r--scalaplugin/src/test/resource/resolve-deps/src/main/scala/Main.scala3
-rw-r--r--scalaplugin/src/test/scala/mill/scalaplugin/AcyclicTests.scala26
-rw-r--r--scalaplugin/src/test/scala/mill/scalaplugin/HelloWorldTests.scala24
-rw-r--r--scalaplugin/src/test/scala/mill/scalaplugin/ResolveDepsTests.scala39
14 files changed, 759 insertions, 23 deletions
diff --git a/scalaplugin/src/main/scala/mill/scalaplugin/Lib.scala b/scalaplugin/src/main/scala/mill/scalaplugin/Lib.scala
index 637906a5..c89254b0 100644
--- a/scalaplugin/src/main/scala/mill/scalaplugin/Lib.scala
+++ b/scalaplugin/src/main/scala/mill/scalaplugin/Lib.scala
@@ -6,7 +6,7 @@ import java.net.URLClassLoader
import java.util.Optional
import ammonite.ops._
-import coursier.{Cache, Fetch, MavenRepository, Repository, Resolution}
+import coursier.{Cache, Fetch, MavenRepository, Repository, Resolution, Module => CoursierModule}
import mill.define.Worker
import mill.eval.{PathRef, Result}
import mill.util.Ctx
@@ -159,7 +159,7 @@ object Lib{
scalaVersion: String,
scalaBinaryVersion: String,
deps: Seq[Dep],
- sources: Boolean = false): Seq[PathRef] = {
+ sources: Boolean = false): Result[Seq[PathRef]] = {
val flattened = deps.map{
case Dep.Java(dep) => dep
case Dep.Scala(dep) =>
@@ -171,15 +171,30 @@ object Lib{
val fetch = Fetch.from(repositories, Cache.fetch())
val resolution = start.process.run(fetch).unsafePerformSync
- val sourceOrJar =
- if (sources) resolution.classifiersArtifacts(Seq("sources"))
- else resolution.artifacts
- val localArtifacts: Seq[File] = scalaz.concurrent.Task
- .gatherUnordered(sourceOrJar.map(Cache.file(_).run))
- .unsafePerformSync
- .flatMap(_.toOption)
-
- localArtifacts.map(p => PathRef(Path(p), quick = true))
+ val errs = resolution.metadataErrors
+ if(errs.nonEmpty) {
+ val header =
+ s"""|
+ |Resolution failed for ${errs.length} modules:
+ |--------------------------------------------
+ |""".stripMargin
+
+ val errLines = errs.map {
+ case ((module, vsn), errMsgs) => s" ${module.trim}:$vsn \n\t" + errMsgs.mkString("\n\t")
+ }.mkString("\n")
+ val msg = header + errLines + "\n"
+ Result.Failure(msg)
+ } else {
+ val sourceOrJar =
+ if (sources) resolution.classifiersArtifacts(Seq("sources"))
+ else resolution.artifacts
+ val localArtifacts: Seq[File] = scalaz.concurrent.Task
+ .gatherUnordered(sourceOrJar.map(Cache.file(_).run))
+ .unsafePerformSync
+ .flatMap(_.toOption)
+
+ localArtifacts.map(p => PathRef(Path(p), quick = true))
+ }
}
def scalaCompilerIvyDeps(scalaVersion: String) = Seq(
Dep.Java("org.scala-lang", "scala-compiler", scalaVersion),
@@ -195,4 +210,4 @@ object Lib{
"#!/usr/bin/env sh",
"exec java -jar \"$0\" \"$@\""
)
-} \ No newline at end of file
+}
diff --git a/scalaplugin/src/main/scala/mill/scalaplugin/ScalaModule.scala b/scalaplugin/src/main/scala/mill/scalaplugin/ScalaModule.scala
index 9f9425a4..949682ca 100644
--- a/scalaplugin/src/main/scala/mill/scalaplugin/ScalaModule.scala
+++ b/scalaplugin/src/main/scala/mill/scalaplugin/ScalaModule.scala
@@ -2,7 +2,7 @@ package mill
package scalaplugin
import ammonite.ops._
-import coursier.{Cache, MavenRepository, Repository, Resolution}
+import coursier.{Cache, MavenRepository, Repository}
import mill.define.Task
import mill.define.Task.{Module, TaskModule}
import mill.eval.{PathRef, Result}
@@ -49,7 +49,8 @@ trait TestScalaModule extends ScalaModule with TaskModule {
}
}
}
-trait ScalaModule extends Module with TaskModule{ outer =>
+
+trait ScalaModule extends Module with TaskModule { outer =>
def defaultCommandName() = "run"
trait Tests extends TestScalaModule{
def scalaVersion = outer.scalaVersion()
@@ -101,6 +102,7 @@ trait ScalaModule extends Module with TaskModule{ outer =>
sources
)
}
+
def externalCompileDepClasspath: T[Seq[PathRef]] = T{
Task.traverse(projectDeps)(_.externalCompileDepClasspath)().flatten ++
resolveDeps(
@@ -115,6 +117,7 @@ trait ScalaModule extends Module with TaskModule{ outer =>
sources = true
)()
}
+
/**
* Things that need to be on the classpath in order for this code to compile;
* might be less than the runtime classpath
@@ -140,8 +143,9 @@ trait ScalaModule extends Module with TaskModule{ outer =>
Seq(dep)
)
classpath match {
- case Seq(single) => PathRef(single.path, quick = true)
- case Seq() => throw new Exception(dep + " resolution failed")
+ case Result.Success(Seq(single)) => PathRef(single.path, quick = true)
+ case Result.Success(Seq()) => throw new Exception(dep + " resolution failed")
+ case f: Result.Failure => throw new Exception(dep + s" resolution failed.\n + ${f.msg}")
case _ => throw new Exception(dep + " resolution resulted in more than one file")
}
}
@@ -195,6 +199,7 @@ trait ScalaModule extends Module with TaskModule{ outer =>
(runDepClasspath().filter(_.path.ext != "pom") ++
Seq(resources(), compile().classes)).map(_.path).filter(exists)
}
+
def assembly = T{
createAssembly(assemblyClasspath(), prependShellScript = prependShellScript())
}
@@ -202,7 +207,34 @@ trait ScalaModule extends Module with TaskModule{ outer =>
def classpath = T{ Seq(resources(), compile().classes) }
def jar = T{
- createJar(Seq(resources(), compile().classes).map(_.path).filter(exists), mainClass())
+ createJar(
+ Seq(resources(), compile().classes).map(_.path).filter(exists),
+ mainClass()
+ )
+ }
+
+ def docsJar = T {
+ val outDir = T.ctx().dest
+
+ val javadocDir = outDir / 'javadoc
+ mkdir(javadocDir)
+
+ val options = {
+ val files = ls.rec(sources().path).filter(_.isFile).map(_.toNIO.toString)
+ files ++ Seq("-d", javadocDir.toNIO.toString, "-usejavacp")
+ }
+
+ subprocess(
+ "scala.tools.nsc.ScalaDoc",
+ compileDepClasspath().filterNot(_.path.ext == "pom").map(_.path),
+ options = options
+ )
+
+ createJar(Seq(javadocDir))(outDir / "javadoc.jar")
+ }
+
+ def sourcesJar = T {
+ createJar(Seq(sources(), resources()).map(_.path).filter(exists))(T.ctx().dest / "sources.jar")
}
def run() = T.command{
@@ -222,6 +254,79 @@ trait ScalaModule extends Module with TaskModule{ outer =>
)
}
}
+
+trait PublishModule extends ScalaModule { outer =>
+ import mill.scalaplugin.publish._
+
+ def publishName: T[String] = basePath.last.toString
+ def publishVersion: T[String] = "0.0.1-SNAPSHOT"
+ def pomSettings: T[PomSettings]
+
+ // publish artifact with name "mill_2.12.4" instead of "mill_2.12"
+ def publishWithFullScalaVersion: Boolean = false
+
+ def artifactScalaVersion: T[String] = T {
+ if (publishWithFullScalaVersion) scalaVersion()
+ else scalaBinaryVersion()
+ }
+
+ def pom = T {
+ val dependencies =
+ ivyDeps().map(Artifact.fromDep(_, scalaVersion(), scalaBinaryVersion()))
+ val pom = Pom(artifact(), dependencies, publishName(), pomSettings())
+
+ val pomPath = T.ctx().dest / s"${publishName()}_${artifactScalaVersion()}-${publishVersion()}.pom"
+ write.over(pomPath, pom)
+ PathRef(pomPath)
+ }
+
+ def ivy = T {
+ val dependencies =
+ ivyDeps().map(Artifact.fromDep(_, scalaVersion(), scalaBinaryVersion()))
+ val ivy = Ivy(artifact(), dependencies)
+ val ivyPath = T.ctx().dest / "ivy.xml"
+ write.over(ivyPath, ivy)
+ PathRef(ivyPath)
+ }
+
+ def artifact: T[Artifact] = T {
+ Artifact(pomSettings().organization, s"${publishName()}_${artifactScalaVersion()}", publishVersion())
+ }
+
+ def publishLocal(): define.Command[Unit] = T.command {
+ LocalPublisher.publish(
+ jar = jar().path,
+ sourcesJar = sourcesJar().path,
+ docsJar = docsJar().path,
+ pom = pom().path,
+ ivy = ivy().path,
+ artifact = artifact()
+ )
+ }
+
+ def sonatypeUri: String = "https://oss.sonatype.org/service/local"
+
+ def sonatypeSnapshotUri: String = "https://oss.sonatype.org/content/repositories/snapshots"
+
+ def publish(credentials: String, gpgPassphrase: String): define.Command[Unit] = T.command {
+ val baseName = s"${publishName()}_${artifactScalaVersion()}-${publishVersion()}"
+ val artifacts = Seq(
+ jar().path -> s"${baseName}.jar",
+ sourcesJar().path -> s"${baseName}-sources.jar",
+ docsJar().path -> s"${baseName}-javadoc.jar",
+ pom().path -> s"${baseName}.pom"
+ )
+ new SonatypePublisher(
+ sonatypeUri,
+ sonatypeSnapshotUri,
+ credentials,
+ gpgPassphrase,
+ T.ctx().log
+ ).publish(artifacts, artifact())
+ }
+
+}
+
trait SbtScalaModule extends ScalaModule { outer =>
def basePath: Path
override def sources = T.source{ basePath / 'src / 'main / 'scala }
diff --git a/scalaplugin/src/main/scala/mill/scalaplugin/publish/Ivy.scala b/scalaplugin/src/main/scala/mill/scalaplugin/publish/Ivy.scala
new file mode 100644
index 00000000..5b2276e1
--- /dev/null
+++ b/scalaplugin/src/main/scala/mill/scalaplugin/publish/Ivy.scala
@@ -0,0 +1,53 @@
+package mill.scalaplugin.publish
+
+import scala.xml.PrettyPrinter
+
+object Ivy {
+
+ val head = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
+
+ def apply(
+ artifact: Artifact,
+ dependencies: Seq[Dependency]
+ ): String = {
+ val xml =
+ <ivy-module version="2.0" xmlns:e="http://ant.apache.org/ivy/extra">
+ <info
+ organisation={artifact.group} module={artifact.id} revision={artifact.version} status="release">
+ <description/>
+ </info>
+ <configurations>
+ <conf name="pom" visibility="public" description=""/>
+ <conf extends="runtime" name="test" visibility="public" description=""/>
+ <conf name="provided" visibility="public" description=""/>
+ <conf name="optional" visibility="public" description=""/>
+ <conf name="compile" visibility="public" description=""/>
+ <conf extends="compile" name="runtime" visibility="public" description=""/>
+ </configurations>
+
+ <publications>
+ <artifact name={artifact.id} type="pom" ext="pom" conf="pom"/>
+ <artifact name={artifact.id} type="jar" ext="jar" conf="compile"/>
+ <artifact name={artifact.id} type="src" ext="jar" conf="compile" e:classifier="sources"/>
+ <artifact name={artifact.id} type="doc" ext="jar" conf="compile" e:classifier="javadoc"/>
+ </publications>
+ <dependencies>{dependencies.map(renderDependency)}</dependencies>
+ </ivy-module>
+
+ val pp = new PrettyPrinter(120, 4)
+ head + pp.format(xml).replaceAll("&gt;", ">")
+ }
+
+ private def renderDependency(dep: Dependency) = {
+ val scope = scopeToConf(dep.scope)
+ <dependency org={dep.artifact.group} name={dep.artifact.id} rev={dep.artifact.version} conf={s"$scope->default(compile)"}></dependency>
+ }
+
+ private def scopeToConf(s: Scope): String = s match {
+ case Scope.Compile => "compile"
+ case Scope.Provided => "provided"
+ case Scope.Test => "test"
+ case Scope.Runtime => "runtime"
+ }
+
+}
diff --git a/scalaplugin/src/main/scala/mill/scalaplugin/publish/JsonFormatters.scala b/scalaplugin/src/main/scala/mill/scalaplugin/publish/JsonFormatters.scala
new file mode 100644
index 00000000..e4aed0ea
--- /dev/null
+++ b/scalaplugin/src/main/scala/mill/scalaplugin/publish/JsonFormatters.scala
@@ -0,0 +1,11 @@
+package mill.scalaplugin.publish
+
+import upickle.default.{ReadWriter => RW}
+
+trait JsonFormatters {
+ implicit lazy val artifactFormat: RW[Artifact] = upickle.default.macroRW
+ implicit lazy val developerFormat: RW[Developer] = upickle.default.macroRW
+ implicit lazy val licenseFormat: RW[License] = upickle.default.macroRW
+ implicit lazy val scmFormat: RW[SCM] = upickle.default.macroRW
+ implicit lazy val pomSettingsFormat: RW[PomSettings] = upickle.default.macroRW
+}
diff --git a/scalaplugin/src/main/scala/mill/scalaplugin/publish/LocalPublisher.scala b/scalaplugin/src/main/scala/mill/scalaplugin/publish/LocalPublisher.scala
new file mode 100644
index 00000000..acec6249
--- /dev/null
+++ b/scalaplugin/src/main/scala/mill/scalaplugin/publish/LocalPublisher.scala
@@ -0,0 +1,33 @@
+package mill.scalaplugin.publish
+
+import ammonite.ops._
+
+object LocalPublisher {
+
+ private val root: Path = home / ".ivy2" / "local"
+
+ def publish(jar: Path,
+ sourcesJar: Path,
+ docsJar: Path,
+ pom: Path,
+ ivy: Path,
+ artifact: Artifact): Unit = {
+ val releaseDir = root / artifact.group / artifact.id / artifact.version
+ writeFiles(
+ jar -> releaseDir / "jars" / s"${artifact.id}.jar",
+ sourcesJar -> releaseDir / "srcs" / s"${artifact.id}-sources.jar",
+ docsJar -> releaseDir / "docs" / s"${artifact.id}-javadoc.jar",
+ pom -> releaseDir / "poms" / s"${artifact.id}.pom",
+ ivy -> releaseDir / "ivys" / "ivy.xml"
+ )
+ }
+
+ private def writeFiles(fromTo: (Path, Path)*): Unit = {
+ fromTo.foreach {
+ case (from, to) =>
+ mkdir(to / up)
+ cp.over(from, to)
+ }
+ }
+
+}
diff --git a/scalaplugin/src/main/scala/mill/scalaplugin/publish/Pom.scala b/scalaplugin/src/main/scala/mill/scalaplugin/publish/Pom.scala
new file mode 100644
index 00000000..fab6c624
--- /dev/null
+++ b/scalaplugin/src/main/scala/mill/scalaplugin/publish/Pom.scala
@@ -0,0 +1,88 @@
+package mill.scalaplugin.publish
+
+import scala.xml.{Elem, NodeSeq, PrettyPrinter}
+
+object Pom {
+
+ val head = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
+
+ //TODO - not only jar packaging support?
+ def apply(artifact: Artifact,
+ dependencies: Seq[Dependency],
+ name: String,
+ pomSettings: PomSettings): String = {
+ val xml =
+ <project
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"
+ xmlns:xsi ="http://www.w3.org/2001/XMLSchema-instance"
+ xmlns ="http://maven.apache.org/POM/4.0.0">
+
+ <modelVersion>4.0.0</modelVersion>
+ <name>{name}</name>
+ <groupId>{artifact.group}</groupId>
+ <artifactId>{artifact.id}</artifactId>
+ <packaging>jar</packaging>
+ <description>{pomSettings.description}</description>
+
+ <version>{artifact.version}</version>
+ <url>{pomSettings.url}</url>
+ <licenses>
+ {pomSettings.licenses.map(renderLicense)}
+ </licenses>
+ <scm>
+ <url>{pomSettings.scm.url}</url>
+ <connection>{pomSettings.scm.connection}</connection>
+ </scm>
+ <developers>
+ {pomSettings.developers.map(renderDeveloper)}
+ </developers>
+ <dependencies>
+ {dependencies.map(renderDependency)}
+ </dependencies>
+ </project>
+
+ val pp = new PrettyPrinter(120, 4)
+ head + pp.format(xml)
+ }
+
+ private def renderLicense(l: License): Elem = {
+ <license>
+ <name>{l.name}</name>
+ <url>{l.url}</url>
+ <distribution>{l.distribution}</distribution>
+ </license>
+ }
+
+ private def renderDeveloper(d: Developer): Elem = {
+ <developer>
+ <id>{d.id}</id>
+ <name>{d.name}</name>
+ {
+ d.organization.map { org =>
+ <organization>{org}</organization>
+ }.getOrElse(NodeSeq.Empty)
+ }
+ {
+ d.organizationUrl.map { orgUrl =>
+ <organizationUrl>{orgUrl}</organizationUrl>
+ }.getOrElse(NodeSeq.Empty)
+ }
+ </developer>
+ }
+
+ private def renderDependency(d: Dependency): Elem = {
+ val scope = d.scope match {
+ case Scope.Compile => NodeSeq.Empty
+ case Scope.Provided => <scope>provided</scope>
+ case Scope.Test => <scope>test</scope>
+ case Scope.Runtime => <scope>runtime</scope>
+ }
+ <dependency>
+ <groupId>{d.artifact.group}</groupId>
+ <artifactId>{d.artifact.id}</artifactId>
+ <version>{d.artifact.version}</version>
+ {scope}
+ </dependency>
+ }
+
+}
diff --git a/scalaplugin/src/main/scala/mill/scalaplugin/publish/SonatypeHttpApi.scala b/scalaplugin/src/main/scala/mill/scalaplugin/publish/SonatypeHttpApi.scala
new file mode 100644
index 00000000..abf65cf6
--- /dev/null
+++ b/scalaplugin/src/main/scala/mill/scalaplugin/publish/SonatypeHttpApi.scala
@@ -0,0 +1,130 @@
+package mill.scalaplugin.publish
+
+import java.util.Base64
+
+import upickle.json
+
+import scala.concurrent.duration._
+import scalaj.http.{BaseHttp, HttpOptions, HttpRequest, HttpResponse}
+
+object PatientHttp
+ extends BaseHttp(
+ options = Seq(
+ HttpOptions.connTimeout(5.seconds.toMillis.toInt),
+ HttpOptions.readTimeout(1.minute.toMillis.toInt),
+ HttpOptions.followRedirects(false)
+ )
+ )
+
+class SonatypeHttpApi(uri: String, credentials: String) {
+
+ private val base64Creds = base64(credentials)
+
+ private val commonHeaders = Seq(
+ "Authorization" -> s"Basic ${base64Creds}",
+ "Accept" -> "application/json",
+ "Content-Type" -> "application/json"
+ )
+
+ // https://oss.sonatype.org/nexus-staging-plugin/default/docs/path__staging_profiles.html
+ def getStagingProfileUri(groupId: String): String = {
+ val response = withRetry(
+ PatientHttp(s"${uri}/staging/profiles").headers(commonHeaders))
+
+ val resourceUri =
+ json
+ .read(response.body)("data")
+ .arr
+ .find(profile => profile("name").str == groupId)
+ .map(_("resourceURI").str.toString)
+
+ resourceUri.getOrElse(
+ throw new RuntimeException(
+ s"Could not find staging profile for groupId: ${groupId}")
+ )
+ }
+
+ def getStagingRepoState(stagingRepoId: String): String = {
+ val response = PatientHttp(s"${uri}/staging/repository/${stagingRepoId}")
+ .option(HttpOptions.readTimeout(60000))
+ .headers(commonHeaders)
+ .asString
+
+ json.read(response.body)("type").str.toString
+ }
+
+ // https://oss.sonatype.org/nexus-staging-plugin/default/docs/path__staging_profiles_-profileIdKey-_start.html
+ def createStagingRepo(profileUri: String, groupId: String): String = {
+ val response = withRetry(PatientHttp(s"${profileUri}/start")
+ .headers(commonHeaders)
+ .postData(
+ s"""{"data": {"description": "fresh staging profile for ${groupId}"}}"""))
+
+ json.read(response.body)("data")("stagedRepositoryId").str.toString
+ }
+
+ // https://oss.sonatype.org/nexus-staging-plugin/default/docs/path__staging_profiles_-profileIdKey-_finish.html
+ def closeStagingRepo(profileUri: String, repositoryId: String): Boolean = {
+ val response = withRetry(
+ PatientHttp(s"${profileUri}/finish")
+ .headers(commonHeaders)
+ .postData(
+ s"""{"data": {"stagedRepositoryId": "${repositoryId}", "description": "closing staging repository"}}"""
+ ))
+
+ response.code == 201
+ }
+
+ // https://oss.sonatype.org/nexus-staging-plugin/default/docs/path__staging_profiles_-profileIdKey-_promote.html
+ def promoteStagingRepo(profileUri: String, repositoryId: String): Boolean = {
+ val response = withRetry(
+ PatientHttp(s"${profileUri}/promote")
+ .headers(commonHeaders)
+ .postData(
+ s"""{"data": {"stagedRepositoryId": "${repositoryId}", "description": "promote staging repository"}}"""
+ ))
+
+ response.code == 201
+ }
+
+ // https://oss.sonatype.org/nexus-staging-plugin/default/docs/path__staging_profiles_-profileIdKey-_drop.html
+ def dropStagingRepo(profileUri: String, repositoryId: String): Boolean = {
+ val response = withRetry(
+ PatientHttp(s"${profileUri}/drop")
+ .headers(commonHeaders)
+ .postData(
+ s"""{"data": {"stagedRepositoryId": "${repositoryId}", "description": "drop staging repository"}}"""
+ ))
+
+ response.code == 201
+ }
+
+ private val uploadTimeout = 5.minutes.toMillis.toInt
+
+ def upload(uri: String, data: Array[Byte]): HttpResponse[String] = {
+ PatientHttp(uri)
+ .option(HttpOptions.readTimeout(uploadTimeout))
+ .method("PUT")
+ .headers(
+ "Content-Type" -> "application/binary",
+ "Authorization" -> s"Basic ${base64Creds}"
+ )
+ .put(data)
+ .asString
+ }
+
+ private def withRetry(request: HttpRequest,
+ retries: Int = 10): HttpResponse[String] = {
+ val resp = request.asString
+ if (resp.is5xx && retries > 0) {
+ Thread.sleep(500)
+ withRetry(request, retries - 1)
+ } else {
+ resp
+ }
+ }
+
+ private def base64(s: String) =
+ new String(Base64.getEncoder.encode(s.getBytes))
+
+}
diff --git a/scalaplugin/src/main/scala/mill/scalaplugin/publish/SonatypePublisher.scala b/scalaplugin/src/main/scala/mill/scalaplugin/publish/SonatypePublisher.scala
new file mode 100644
index 00000000..90a39745
--- /dev/null
+++ b/scalaplugin/src/main/scala/mill/scalaplugin/publish/SonatypePublisher.scala
@@ -0,0 +1,148 @@
+package mill.scalaplugin.publish
+
+import java.math.BigInteger
+import java.security.MessageDigest
+
+import ammonite.ops._
+import mill.util.Logger
+
+import scalaj.http.HttpResponse
+
+class SonatypePublisher(uri: String,
+ snapshotUri: String,
+ credentials: String,
+ gpgPassphrase: String,
+ log: Logger) {
+
+ private val api = new SonatypeHttpApi(uri, credentials)
+
+ def publish(artifacts: Seq[(Path, String)], artifact: Artifact): Unit = {
+ val signedArtifacts = artifacts ++ artifacts.map {
+ case (file, name) =>
+ poorMansSign(file, gpgPassphrase) -> s"${name}.asc"
+ }
+
+ val signedArtifactsWithDigest = signedArtifacts.flatMap {
+ case (file, name) =>
+ val content = read.bytes(file)
+
+ Seq(
+ name -> content,
+ (name + ".md5") -> md5hex(content),
+ (name + ".sha1") -> sha1hex(content)
+ )
+ }
+
+ val publishPath = Seq(
+ artifact.group.replace(".", "/"),
+ artifact.id,
+ artifact.version
+ ).mkString("/")
+
+ if (artifact.isSnapshot)
+ publishSnapshot(publishPath, signedArtifactsWithDigest, artifact)
+ else
+ publishRelease(publishPath, signedArtifactsWithDigest, artifact)
+ }
+
+ private def publishSnapshot(publishPath: String,
+ payloads: Seq[(String, Array[Byte])],
+ artifact: Artifact): Unit = {
+ val baseUri: String = snapshotUri + "/" + publishPath
+
+ val publishResults = payloads.map {
+ case (fileName, data) =>
+ log.info(s"Uploading ${fileName}")
+ val resp = api.upload(s"${baseUri}/${fileName}", data)
+ resp
+ }
+ reportPublishResults(publishResults, artifact)
+ }
+
+ private def publishRelease(publishPath: String,
+ payloads: Seq[(String, Array[Byte])],
+ artifact: Artifact): Unit = {
+ val profileUri = api.getStagingProfileUri(artifact.group)
+ val stagingRepoId =
+ api.createStagingRepo(profileUri, artifact.group)
+ val baseUri =
+ s"${uri}/staging/deployByRepositoryId/${stagingRepoId}/${publishPath}"
+
+ val publishResults = payloads.map {
+ case (fileName, data) =>
+ log.info(s"Uploading ${fileName}")
+ api.upload(s"${baseUri}/${fileName}", data)
+ }
+ reportPublishResults(publishResults, artifact)
+
+ log.info("Closing staging repository")
+ api.closeStagingRepo(profileUri, stagingRepoId)
+
+ log.info("Waiting for staging repository to close")
+ awaitRepoStatus("closed", stagingRepoId)
+
+ log.info("Promoting staging repository")
+ api.promoteStagingRepo(profileUri, stagingRepoId)
+
+ log.info("Waiting for staging repository to release")
+ awaitRepoStatus("released", stagingRepoId)
+
+ log.info("Dropping staging repository")
+ api.dropStagingRepo(profileUri, stagingRepoId)
+
+ log.info(s"Published ${artifact.id} successfully")
+ }
+
+ private def reportPublishResults(publishResults: Seq[HttpResponse[String]],
+ artifact: Artifact) = {
+ if (publishResults.forall(_.is2xx)) {
+ log.info(s"Published ${artifact.id} to Sonatype")
+ } else {
+ val errors = publishResults.filterNot(_.is2xx).map { response =>
+ s"Code: ${response.code}, message: ${response.body}"
+ }
+ throw new RuntimeException(
+ s"Failed to publish ${artifact.id} to Sonatype. Errors: \n${errors.mkString("\n")}"
+ )
+ }
+ }
+
+ private def awaitRepoStatus(status: String,
+ stagingRepoId: String,
+ attempts: Int = 20): Unit = {
+ def isRightStatus =
+ api.getStagingRepoState(stagingRepoId).equalsIgnoreCase(status)
+ var attemptsLeft = attempts
+
+ while (attemptsLeft > 0 && !isRightStatus) {
+ Thread.sleep(3000)
+ attemptsLeft -= 1
+ if (attemptsLeft == 0) {
+ throw new RuntimeException(
+ s"Couldn't wait for staging repository to be ${status}. Failing")
+ }
+ }
+ }
+
+ // http://central.sonatype.org/pages/working-with-pgp-signatures.html#signing-a-file
+ private def poorMansSign(file: Path, passphrase: String): Path = {
+ val fileName = file.toString
+ import ammonite.ops.ImplicitWd._
+ %("gpg", "--yes", "-a", "-b", "--passphrase", passphrase, fileName)
+ Path(fileName + ".asc")
+ }
+
+ private def md5hex(bytes: Array[Byte]): Array[Byte] =
+ hexArray(md5.digest(bytes)).getBytes
+
+ private def sha1hex(bytes: Array[Byte]): Array[Byte] =
+ hexArray(sha1.digest(bytes)).getBytes
+
+ private def md5 = MessageDigest.getInstance("md5")
+
+ private def sha1 = MessageDigest.getInstance("sha1")
+
+ private def hexArray(arr: Array[Byte]) =
+ String.format("%0" + (arr.length << 1) + "x", new BigInteger(1, arr))
+
+}
diff --git a/scalaplugin/src/main/scala/mill/scalaplugin/publish/package.scala b/scalaplugin/src/main/scala/mill/scalaplugin/publish/package.scala
new file mode 100644
index 00000000..1b405b90
--- /dev/null
+++ b/scalaplugin/src/main/scala/mill/scalaplugin/publish/package.scala
@@ -0,0 +1,3 @@
+package mill.scalaplugin
+
+package object publish extends JsonFormatters
diff --git a/scalaplugin/src/main/scala/mill/scalaplugin/publish/settings.scala b/scalaplugin/src/main/scala/mill/scalaplugin/publish/settings.scala
new file mode 100644
index 00000000..e13825ab
--- /dev/null
+++ b/scalaplugin/src/main/scala/mill/scalaplugin/publish/settings.scala
@@ -0,0 +1,70 @@
+package mill.scalaplugin.publish
+
+import mill.scalaplugin.Dep
+
+case class Artifact(group: String, id: String, version: String) {
+ def isSnapshot: Boolean = version.endsWith("-SNAPSHOT")
+}
+
+object Artifact {
+
+ def fromDep(dep: Dep, scalaFull: String, scalaBin: String): Dependency = {
+ dep match {
+ case Dep.Java(dep) =>
+ Dependency(
+ Artifact(dep.module.organization, dep.module.name, dep.version),
+ Scope.Compile)
+ case Dep.Scala(dep) =>
+ Dependency(Artifact(dep.module.organization,
+ s"${dep.module.name}_${scalaBin}",
+ dep.version),
+ Scope.Compile)
+ case Dep.Point(dep) =>
+ Dependency(Artifact(dep.module.organization,
+ s"${dep.module.name}_${scalaFull}",
+ dep.version),
+ Scope.Compile)
+ }
+ }
+}
+
+sealed trait Scope
+object Scope {
+ case object Compile extends Scope
+ case object Provided extends Scope
+ case object Runtime extends Scope
+ case object Test extends Scope
+}
+
+case class Dependency(
+ artifact: Artifact,
+ scope: Scope
+)
+
+case class License(
+ name: String,
+ url: String,
+ distribution: String = "repo"
+)
+
+case class SCM(
+ url: String,
+ connection: String
+)
+
+case class Developer(
+ id: String,
+ name: String,
+ url: String,
+ organization: Option[String] = None,
+ organizationUrl: Option[String] = None
+)
+
+case class PomSettings(
+ description: String,
+ organization: String,
+ url: String,
+ licenses: Seq[License],
+ scm: SCM,
+ developers: Seq[Developer]
+)
diff --git a/scalaplugin/src/test/resource/resolve-deps/src/main/scala/Main.scala b/scalaplugin/src/test/resource/resolve-deps/src/main/scala/Main.scala
new file mode 100644
index 00000000..5dcbe39a
--- /dev/null
+++ b/scalaplugin/src/test/resource/resolve-deps/src/main/scala/Main.scala
@@ -0,0 +1,3 @@
+object Main {
+ println("ResolveDeps Main: hello world!")
+}
diff --git a/scalaplugin/src/test/scala/mill/scalaplugin/AcyclicTests.scala b/scalaplugin/src/test/scala/mill/scalaplugin/AcyclicTests.scala
index deaa766e..42d93c2a 100644
--- a/scalaplugin/src/test/scala/mill/scalaplugin/AcyclicTests.scala
+++ b/scalaplugin/src/test/scala/mill/scalaplugin/AcyclicTests.scala
@@ -2,20 +2,36 @@ package mill.scalaplugin
import ammonite.ops.ImplicitWd._
import ammonite.ops._
-import mill.define.{Cross,Task}
+import mill.define.{Cross, Task}
import mill.discover.Discovered
import mill.eval.Result
+import mill.scalaplugin.publish._
import utest._
import mill.util.JsonFormatters._
object AcyclicBuild{
val acyclic =
for(crossVersion <- Cross("2.10.6", "2.11.8", "2.12.3", "2.12.4"))
- yield new SbtScalaModule{outer =>
+ yield new SbtScalaModule with PublishModule {outer =>
def basePath = AcyclicTests.workspacePath
- def organization = "com.lihaoyi"
- def name = "acyclic"
+ def publishName = "acyclic"
+ def publishVersion = "0.1.7"
+
+ def pomSettings = PomSettings(
+ description = publishName(),
+ organization = "com.lihaoyi",
+ url = "https://github.com/lihaoyi/acyclic",
+ licenses = Seq(
+ License("MIT license", "http://www.opensource.org/licenses/mit-license.php")
+ ),
+ scm = SCM(
+ "git://github.com/lihaoyi/acyclic.git",
+ "scm:git://github.com/lihaoyi/acyclic.git"
+ ),
+ developers = Seq(
+ Developer("lihaoyi", "Li Haoyi","https://github.com/lihaoyi")
+ )
+ )
- def version = "0.1.7"
def scalaVersion = crossVersion
def ivyDeps = Seq(
Dep.Java("org.scala-lang", "scala-compiler", scalaVersion())
diff --git a/scalaplugin/src/test/scala/mill/scalaplugin/HelloWorldTests.scala b/scalaplugin/src/test/scala/mill/scalaplugin/HelloWorldTests.scala
index a5e52330..41783efb 100644
--- a/scalaplugin/src/test/scala/mill/scalaplugin/HelloWorldTests.scala
+++ b/scalaplugin/src/test/scala/mill/scalaplugin/HelloWorldTests.scala
@@ -7,6 +7,7 @@ import mill.define.{Target, Task}
import mill.discover.Discovered
import mill.discover.Mirror.LabelledTarget
import mill.eval.Result
+import mill.scalaplugin.publish._
import sbt.internal.inc.CompileFailed
import utest._
@@ -29,6 +30,26 @@ object HelloWorldFatalWarnings extends HelloWorldModule {
def scalacOptions = T(Seq("-Ywarn-unused", "-Xfatal-warnings"))
}
+object HelloWorldWithPublish extends HelloWorldModule with PublishModule {
+ def publishName = "hello-world"
+ def publishVersion = "0.0.1"
+
+ def pomSettings = PomSettings(
+ organization = "com.lihaoyi",
+ description = "hello world ready for real world publishing",
+ url = "https://github.com/lihaoyi/hello-world-publish",
+ licenses = Seq(
+ License("Apache License, Version 2.0",
+ "http://www.apache.org/licenses/LICENSE-2.0")),
+ scm = SCM(
+ "https://github.com/lihaoyi/hello-world-publish",
+ "scm:git:https://github.com/lihaoyi/hello-world-publish"
+ ),
+ developers =
+ Seq(Developer("lihaoyi", "Li Haoyi", "https://github.com/lihaoyi"))
+ )
+}
+
object HelloWorldTests extends TestSuite {
val srcPath = pwd / 'scalaplugin / 'src / 'test / 'resource / "hello-world"
@@ -98,7 +119,8 @@ object HelloWorldTests extends TestSuite {
val outPath = result.classes.path
val analysisFile = result.analysisFile
val outputFiles = ls.rec(outPath)
- val expectedClassfiles = compileClassfiles(outputPath / 'compile / 'classes)
+ val expectedClassfiles =
+ compileClassfiles(outputPath / 'compile / 'classes)
assert(
outPath == outputPath / 'compile / 'classes,
exists(analysisFile),
diff --git a/scalaplugin/src/test/scala/mill/scalaplugin/ResolveDepsTests.scala b/scalaplugin/src/test/scala/mill/scalaplugin/ResolveDepsTests.scala
new file mode 100644
index 00000000..cd6bd40f
--- /dev/null
+++ b/scalaplugin/src/test/scala/mill/scalaplugin/ResolveDepsTests.scala
@@ -0,0 +1,39 @@
+package mill.scalaplugin
+
+import coursier.Cache
+import coursier.maven.MavenRepository
+import mill.eval.Result.{Failure, Success}
+import mill.eval.{PathRef, Result}
+import utest._
+
+object ResolveDepsTests extends TestSuite {
+ val repos = Seq(Cache.ivy2Local, MavenRepository("https://repo1.maven.org/maven2"))
+
+ def evalDeps(deps: Seq[Dep]): Result[Seq[PathRef]] = Lib.resolveDependencies(repos, "2.12.4", "2.12", deps)
+
+ val tests = Tests {
+ 'resolveValidDeps - {
+ val deps = Seq(Dep("com.lihaoyi", "pprint", "0.5.3"))
+ val Success(paths) = evalDeps(deps)
+ assert(paths.nonEmpty)
+ }
+
+ 'errOnInvalidOrgDeps - {
+ val deps = Seq(Dep("xxx.yyy.invalid", "pprint", "0.5.3"))
+ val Failure(errMsg) = evalDeps(deps)
+ assert(errMsg.contains("xxx.yyy.invalid"))
+ }
+
+ 'errOnInvalidVersionDeps - {
+ val deps = Seq(Dep("com.lihaoyi", "pprint", "invalid.version.num"))
+ val Failure(errMsg) = evalDeps(deps)
+ assert(errMsg.contains("invalid.version.num"))
+ }
+
+ 'errOnPartialSuccess - {
+ val deps = Seq(Dep("com.lihaoyi", "pprint", "0.5.3"), Dep("fake", "fake", "fake"))
+ val Failure(errMsg) = evalDeps(deps)
+ assert(errMsg.contains("fake"))
+ }
+ }
+}