diff options
author | rockjam <5min4eq.unity@gmail.com> | 2017-12-10 23:33:46 +0300 |
---|---|---|
committer | Nikolay Tatarinov <5min4eq.unity@gmail.com> | 2017-12-20 01:17:16 +0300 |
commit | 9d19d740c5b387704e08fb89412b8318549a4fc5 (patch) | |
tree | 203c7ede2212c916195a82c2cf6578e9c755de7b /scalaplugin | |
parent | 6a7f6cf910362cadf6b39f8c0795c46745e08742 (diff) | |
download | mill-9d19d740c5b387704e08fb89412b8318549a4fc5.tar.gz mill-9d19d740c5b387704e08fb89412b8318549a4fc5.tar.bz2 mill-9d19d740c5b387704e08fb89412b8318549a4fc5.zip |
Add Sonatypype publishing;
now you can publish your module with `mill run MyModule.publish --credentials $SONATYPE_CREDENTIALS --gpgPassphrase $GPG_PASSPHRASE`
Diffstat (limited to 'scalaplugin')
13 files changed, 589 insertions, 261 deletions
diff --git a/scalaplugin/src/main/scala/mill/scalaplugin/ScalaModule.scala b/scalaplugin/src/main/scala/mill/scalaplugin/ScalaModule.scala index f7cb8963..949682ca 100644 --- a/scalaplugin/src/main/scala/mill/scalaplugin/ScalaModule.scala +++ b/scalaplugin/src/main/scala/mill/scalaplugin/ScalaModule.scala @@ -3,11 +3,12 @@ package scalaplugin import ammonite.ops._ import coursier.{Cache, MavenRepository, Repository} -import mill.define.{Source, Task} +import mill.define.Task import mill.define.Task.{Module, TaskModule} import mill.eval.{PathRef, Result} import mill.modules.Jvm import mill.modules.Jvm.{createAssembly, createJar, interactiveSubprocess, subprocess} + import Lib._ trait TestScalaModule extends ScalaModule with TaskModule { override def defaultCommandName() = "test" @@ -48,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() @@ -197,38 +199,42 @@ trait ScalaModule extends Module with TaskModule{ outer => (runDepClasspath().filter(_.path.ext != "pom") ++ Seq(resources(), compile().classes)).map(_.path).filter(exists) } + def assembly = T{ - val outDir = T.ctx().dest/up - val n = name() - val v = version() - val jarName = s"${n}-${v}.jar" - val dest = outDir/jarName - createAssembly(dest, assemblyClasspath(), prependShellScript = prependShellScript()) + createAssembly(assemblyClasspath(), prependShellScript = prependShellScript()) } def classpath = T{ Seq(resources(), compile().classes) } def jar = T{ - val outDir = T.ctx().dest/up - val n = name() - val v = version() - val jarName = s"${n}-${v}.jar" - val dest = outDir/jarName - createJar(dest, Seq(resources(), compile().classes).map(_.path).filter(exists), mainClass()) - PathRef(dest) + createJar( + Seq(resources(), compile().classes).map(_.path).filter(exists), + mainClass() + ) } - def sourcesJar = T{ - val outDir = T.ctx().dest/up - val n = name() - val v = version() - val jarName = s"${n}-${v}-sources.jar" - val dest = outDir/jarName + def docsJar = T { + val outDir = T.ctx().dest - val inputs = Seq(sources(), resources()).map(_.path).filter(exists) + 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(dest, inputs) - PathRef(dest) + 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{ @@ -247,28 +253,80 @@ trait ScalaModule extends Module with TaskModule{ outer => options = Seq("-usejavacp") ) } +} + +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 organization: T[String] = "acme" - def name: T[String] = pwd.last.toString - def version: T[String] = "0.0.1-SNAPSHOT" - - // build artifact name as "mill-2.12.4" instead of "mill-2.12" - def useFullScalaVersionForPublish: Boolean = false - - def publishLocal() = T.command { - import publish._ - val file = jar() - val scalaFull = scalaVersion() - val scalaBin = scalaBinaryVersion() - val useFullVersion = useFullScalaVersionForPublish - val deps = ivyDeps() - val dependencies = deps.map(d => Artifact.fromDep(d, scalaFull, scalaBin)) - val artScalaVersion = if (useFullVersion) scalaFull else scalaBin - val artifact = ScalaArtifact(organization(), name(), version(), artScalaVersion) - LocalPublisher.publish(file, artifact, dependencies) + 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(">", ">") + } + + 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/IvyFile.scala b/scalaplugin/src/main/scala/mill/scalaplugin/publish/IvyFile.scala deleted file mode 100644 index c9f36ebe..00000000 --- a/scalaplugin/src/main/scala/mill/scalaplugin/publish/IvyFile.scala +++ /dev/null @@ -1,63 +0,0 @@ -package mill.scalaplugin.publish - -trait IvyFile { - - val head = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" - - def generateIvy( - artifact: Artifact, - dependencies: Seq[Dependency], - pomSettings: PomSettings - ): String = { - val xml = ivyXml(artifact, dependencies, pomSettings) - head + xml - } - - // can't use scala-xml - // it escapes '->' inside dependency conf - def ivyXml( - artifact: Artifact, - dependencies: Seq[Dependency], - pomSettings: PomSettings - ): String = { - val deps = dependencies.map(d => { - import d.artifact._ - val scope = scopeToConf(d.scope) - s""" <dependency org="${group}" name="${id}" rev="${version}" conf="${scope}->default(compile)"> - | </dependency> """.stripMargin - }).mkString("\n") - s"""<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"/> - | </publications> - | <dependencies> - |${deps} - | </dependencies> - |</ivy-module> - """.stripMargin - } - - private def scopeToConf(s: Scope): String = s match { - case Scope.Compile => "compile" - case Scope.Provided => "provided" - case Scope.Test => "test" - case Scope.Runtime => "runtime" - } - -} - -object IvyFile extends IvyFile 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/PomFile.scala b/scalaplugin/src/main/scala/mill/scalaplugin/publish/Pom.scala index 95f05761..fab6c624 100644 --- a/scalaplugin/src/main/scala/mill/scalaplugin/publish/PomFile.scala +++ b/scalaplugin/src/main/scala/mill/scalaplugin/publish/Pom.scala @@ -1,19 +1,16 @@ package mill.scalaplugin.publish -import scala.xml.{Elem, NodeSeq} +import scala.xml.{Elem, NodeSeq, PrettyPrinter} - -trait PomFile { +object Pom { val head = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" //TODO - not only jar packaging support? - //TODO - description - def generatePom( - artifact: Artifact, - dependencies: Seq[Dependency], - pomSettings: PomSettings - ): String = { + 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" @@ -21,9 +18,11 @@ trait PomFile { 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></description> + <description>{pomSettings.description}</description> <version>{artifact.version}</version> <url>{pomSettings.url}</url> @@ -42,49 +41,48 @@ trait PomFile { </dependencies> </project> - val pp = new scala.xml.PrettyPrinter(120, 4) - val data = pp.format(xml) - head + data + val pp = new PrettyPrinter(120, 4) + head + pp.format(xml) } private def renderLicense(l: License): Elem = { - import l._ <license> - <name>{name}</name> - <url>{url}</url> - <distribution>{distribution}</distribution> + <name>{l.name}</name> + <url>{l.url}</url> + <distribution>{l.distribution}</distribution> </license> } private def renderDeveloper(d: Developer): Elem = { - import d._ <developer> - <id>{id}</id> - <name>{name}</name> - <organization>{organization}</organization> - <organizationUrl>{organizationUrl}</organizationUrl> + <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 = { - import d._ - import artifact._ - val scope = d.scope match { - case Scope.Compile => NodeSeq.Empty + case Scope.Compile => NodeSeq.Empty case Scope.Provided => <scope>provided</scope> - case Scope.Test => <scope>test</scope> - case Scope.Runtime => <scope>runtime</scope> + case Scope.Test => <scope>test</scope> + case Scope.Runtime => <scope>runtime</scope> } <dependency> - <groupId>{group}</groupId> - <artifactId>{id}</artifactId> - <version>{version}</version> + <groupId>{d.artifact.group}</groupId> + <artifactId>{d.artifact.id}</artifactId> + <version>{d.artifact.version}</version> {scope} </dependency> } } - -object PomFile extends PomFile - diff --git a/scalaplugin/src/main/scala/mill/scalaplugin/publish/Publisher.scala b/scalaplugin/src/main/scala/mill/scalaplugin/publish/Publisher.scala deleted file mode 100644 index 99cd13b5..00000000 --- a/scalaplugin/src/main/scala/mill/scalaplugin/publish/Publisher.scala +++ /dev/null @@ -1,62 +0,0 @@ -package mill.scalaplugin.publish - -import java.io.File -import java.nio.file.{Files, StandardCopyOption, StandardOpenOption} -import java.security.MessageDigest - -import ammonite.ops._ -import mill.eval.PathRef - -object LocalPublisher { - - val root: Path = { - val ivy2 = { - // a bit touchy on Windows... - don't try to manually write down the URI with s"file://..." - val str = new File(sys.props("user.home") + "/.ivy2/").toString - if (str.endsWith("/")) str else str + "/" - } - Path(ivy2 + "local/") - } - - def publish( - file: PathRef, - artifact: Artifact, - dependencies: Seq[Dependency] - ): String = { - val sett = PomSettings("mill", "url", Seq.empty, SCM("", ""), Seq.empty) - val f = file.path - val pomData = PomFile.generatePom(artifact, dependencies, sett) - val ivyData = IvyFile.generateIvy(artifact, dependencies, sett) - - - val dir = root/artifact.group/artifact.id/artifact.version - - val jars = dir/"jars" - val poms = dir/"poms" - val ivys = dir/"ivys" - - val fileName = artifact match { - case j: JavaArtifact => j.name - case sa: ScalaArtifact => - val postfix = { - val arr = sa.scalaVersion.split('.') - val erased = if (arr.length > 2) arr.dropRight(1) else arr - erased.mkString(".") - } - s"${sa.name}_$postfix" - } - - Seq(dir, jars, poms, ivys).foreach(d => if (!d.toIO.exists()) mkdir(d)) - - Files.copy(f.toNIO, (dir/"jars"/s"$fileName.jar").toNIO, StandardCopyOption.REPLACE_EXISTING) - Files.write((dir/"poms"/s"$fileName.pom").toNIO, pomData.getBytes(), - StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING - ) - Files.write((dir/"ivys"/"ivy.xml").toNIO, ivyData.getBytes(), - StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING - ) - - dir.toString() - } - -} 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 index ece7fbd1..e13825ab 100644 --- a/scalaplugin/src/main/scala/mill/scalaplugin/publish/settings.scala +++ b/scalaplugin/src/main/scala/mill/scalaplugin/publish/settings.scala @@ -1,53 +1,33 @@ package mill.scalaplugin.publish import mill.scalaplugin.Dep -import mill.util.JsonFormatters -trait Artifact extends Serializable with Product { - def group: String - def name: String - def id: String - def version: String +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 d: Dep.Java => - import d.dep._ - val art = JavaArtifact(module.organization, module.name, version) - Dependency(art, Scope.Compile) - case d: Dep.Scala => - import d.dep._ - val art = ScalaArtifact(module.organization, module.name, version, scalaBin) - Dependency(art, Scope.Compile) - case d: Dep.Point => - import d.dep._ - val art = ScalaArtifact(module.organization, module.name, version, scalaFull) - Dependency(art, Scope.Compile) + 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) } } } -case class JavaArtifact( - group: String, - name: String, - version: String -) extends Artifact { - override def id: String = name -} - -case class ScalaArtifact( - group: String, - name: String, - version: String, - scalaVersion: String, -) extends Artifact { - - override def id: String = s"${name}_$scalaVersion" -} - sealed trait Scope object Scope { case object Compile extends Scope @@ -57,34 +37,34 @@ object Scope { } case class Dependency( - artifact: Artifact, - scope: Scope + artifact: Artifact, + scope: Scope ) case class License( - name: String, - url: String, - distribution: String = "repo" + name: String, + url: String, + distribution: String = "repo" ) case class SCM( - url: String, - connection: String + url: String, + connection: String ) case class Developer( - id: String, - name: String, - url: String, - organization: String, - organizationUrl: String + id: String, + name: String, + url: String, + organization: Option[String] = None, + organizationUrl: Option[String] = None ) case class PomSettings( - organization: String, - url: String, - licenses: Seq[License], - scm: SCM, - developers: Seq[Developer] + description: String, + organization: String, + url: String, + licenses: Seq[License], + scm: SCM, + developers: Seq[Developer] ) - diff --git a/scalaplugin/src/test/scala/mill/scalaplugin/AcyclicTests.scala b/scalaplugin/src/test/scala/mill/scalaplugin/AcyclicTests.scala index 18f20852..84ff5407 100644 --- a/scalaplugin/src/test/scala/mill/scalaplugin/AcyclicTests.scala +++ b/scalaplugin/src/test/scala/mill/scalaplugin/AcyclicTests.scala @@ -2,19 +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 - override def organization = "com.lihaoyi" - override def name = "acyclic" - override def version = "0.1.7" + 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 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 239f6871..8d535a35 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), |