From 0217c31121657baeaf0cc0b4d455cb66ad146433 Mon Sep 17 00:00:00 2001 From: Alexandre Archambault Date: Wed, 4 May 2022 20:52:50 +0200 Subject: [PATCH] Allow to publish to Maven Central in publish command --- .../publish/SharedPublishOptions.scala | 10 +++ .../scala/cli/commands/publish/Publish.scala | 87 +++++++++++++++---- .../commands/publish/RepositoryParser.scala | 76 ++++++++++++++++ .../UsingPublishDirectiveHandler.scala | 8 +- .../scala/build/options/PublishOptions.scala | 2 + website/docs/reference/cli-options.md | 8 ++ 6 files changed, 175 insertions(+), 16 deletions(-) create mode 100644 modules/cli/src/main/scala/scala/cli/commands/publish/RepositoryParser.scala diff --git a/modules/cli-options/src/main/scala/scala/cli/commands/publish/SharedPublishOptions.scala b/modules/cli-options/src/main/scala/scala/cli/commands/publish/SharedPublishOptions.scala index c100036b49..47d2905301 100644 --- a/modules/cli-options/src/main/scala/scala/cli/commands/publish/SharedPublishOptions.scala +++ b/modules/cli-options/src/main/scala/scala/cli/commands/publish/SharedPublishOptions.scala @@ -48,6 +48,16 @@ final case class SharedPublishOptions( @ExtraName("publishRepo") publishRepository: Option[String] = None, + @Group("Publishing") + @HelpMessage("User to use with publishing repository") + @ValueDescription("user") + user: Option[PasswordOption] = None, + + @Group("Publishing") + @HelpMessage("Password to use with publishing repository") + @ValueDescription("value:…") + password: Option[PasswordOption] = None, + @Group("Publishing") @HelpMessage("Secret key to use to sign artifacts with BouncyCastle") secretKey: Option[PasswordOption] = None, diff --git a/modules/cli/src/main/scala/scala/cli/commands/publish/Publish.scala b/modules/cli/src/main/scala/scala/cli/commands/publish/Publish.scala index a5f3cffaee..05527d81b5 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/publish/Publish.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/publish/Publish.scala @@ -2,33 +2,37 @@ package scala.cli.commands.publish import caseapp.core.RemainingArgs import coursier.cache.ArchiveCache -import coursier.core.Configuration +import coursier.core.{Authentication, Configuration} import coursier.maven.MavenRepository import coursier.publish.checksum.logger.InteractiveChecksumLogger import coursier.publish.checksum.{ChecksumType, Checksums} import coursier.publish.fileset.{FileSet, Path} import coursier.publish.signing.logger.InteractiveSignerLogger import coursier.publish.signing.{GpgSigner, NopSigner, Signer} +import coursier.publish.sonatype.SonatypeApi import coursier.publish.upload.logger.InteractiveUploadLogger import coursier.publish.upload.{FileUpload, HttpURLConnectionUpload} -import coursier.publish.{Content, Pom} +import coursier.publish.{Content, Hooks, Pom, PublishRepository} import java.io.OutputStreamWriter import java.net.URI import java.nio.charset.StandardCharsets import java.nio.file.{Path => NioPath, Paths} import java.time.Instant +import java.util.concurrent.Executors import java.util.function.Supplier import scala.build.EitherCps.{either, value} import scala.build.Ops._ import scala.build.errors.{BuildException, CompositeBuildException, NoMainClassFoundError} +import scala.build.internal.Util import scala.build.internal.Util.ScalaDependencyOps import scala.build.options.publish.{ComputeVersion, Developer, License, Signer => PSigner, Vcs} import scala.build.options.{BuildOptions, ConfigMonoid, Scope} import scala.build.{Build, BuildThreads, Builds, Logger, Os, Positioned} import scala.cli.CurrentParams import scala.cli.commands.pgp.PgpExternalCommand +import scala.cli.commands.util.ScalaCliSttpBackend import scala.cli.commands.util.SharedOptionsUtil._ import scala.cli.commands.{Package => PackageCmd, ScalaCommand, WatchUtil} import scala.cli.errors.{FailedToSignFileError, MissingPublishOptionError, UploadError} @@ -90,6 +94,8 @@ object Publish extends ScalaCommand[PublishOptions] { gpgOptions = gpgOption, secretKey = sharedPublish.secretKey, secretKeyPassword = sharedPublish.secretKeyPassword, + repoUser = sharedPublish.user, + repoPassword = sharedPublish.password, signer = value { signer .map(Positioned.commandLine(_)) @@ -218,7 +224,7 @@ object Publish extends ScalaCommand[PublishOptions] { workingDir: os.Path, now: Instant, logger: Logger - ): Either[BuildException, FileSet] = either { + ): Either[BuildException, (FileSet, String)] = either { logger.debug(s"Preparing project ${build.project.projectName}") @@ -353,7 +359,7 @@ object Publish extends ScalaCommand[PublishOptions] { .toSeq // TODO version listings, … - FileSet(mainEntries ++ sourceJarEntries ++ docJarEntries) + (FileSet(mainEntries ++ sourceJarEntries ++ docJarEntries), ver) } private def doPublish( @@ -371,7 +377,7 @@ object Publish extends ScalaCommand[PublishOptions] { } val now = Instant.now() - val fileSet0 = value { + val (fileSet0, versionOpt) = value { it // TODO Allow to add test JARs to the main build artifacts .filter(_._1.scope != Scope.Test) @@ -380,10 +386,16 @@ object Publish extends ScalaCommand[PublishOptions] { buildFileSet(build, docBuildOpt, workingDir, now, logger) } .sequence0 - .map(_.foldLeft(FileSet.empty)(_ ++ _)) + .map { l => + val fs = l.map(_._1).foldLeft(FileSet.empty)(_ ++ _) + val versionOpt0 = l.headOption.map(_._2) + (fs, versionOpt0) + } } val ec = builds.head.options.finalCache.ec + lazy val es = + Executors.newSingleThreadScheduledExecutor(Util.daemonThreadFactory("publish-retry")) val publishOptions = ConfigMonoid.sum( builds.map(_.options.notForBloopOptions.publishOptions) @@ -462,37 +474,82 @@ object Publish extends ScalaCommand[PublishOptions] { val finalFileSet = fileSet2.order(ec).unsafeRun()(ec) - val repo = publishOptions.repository match { + lazy val authOpt = { + val userOpt = publishOptions.repoUser + val passwordOpt = publishOptions.repoPassword.map(_.get()) + passwordOpt.map { password => + Authentication(userOpt.fold("")(_.get().value), password.value) + } + } + + def centralRepo(base: String) = { + val repo0 = { + val r = PublishRepository.Sonatype(MavenRepository(base)) + authOpt.fold(r)(r.withAuthentication) + } + val backend = ScalaCliSttpBackend.httpURLConnection(logger) + val api = SonatypeApi(backend, base + "/service/local", authOpt, logger.verbosity) + val hooks0 = Hooks.sonatype( + repo0, + api, + logger.compilerOutputStream, // meh + logger.verbosity, + batch = coursier.paths.Util.useAnsiOutput(), // FIXME Get via logger + es + ) + (repo0, hooks0) + } + + val (repo, hooks) = publishOptions.repository match { case None => value(Left(new MissingPublishOptionError( "repository", "--publish-repository", "publish.repository" ))) - case Some(repo) => - val url = - if (repo.contains("://")) repo - else os.Path(repo, Os.pwd).toNIO.toUri.toASCIIString - MavenRepository(url) + case Some("central" | "maven-central" | "mvn-central") => + centralRepo("https://oss.sonatype.org") + case Some("central-s01" | "maven-central-s01" | "mvn-central-s01") => + centralRepo("https://s01.oss.sonatype.org") + case Some(repoStr) => + val repo0 = RepositoryParser.repositoryOpt(repoStr) + .collect { + case m: MavenRepository => + m + } + .getOrElse { + val url = + if (repoStr.contains("://")) repoStr + else os.Path(repoStr, Os.pwd).toNIO.toUri.toASCIIString + MavenRepository(url) + } + (PublishRepository.Simple(repo0), Hooks.dummy) } + val isSnapshot0 = versionOpt.exists(_.endsWith("SNAPSHOT")) + val hooksData = hooks.beforeUpload(finalFileSet, isSnapshot0).unsafeRun()(ec) + + val retainedRepo = hooks.repository(hooksData, repo, isSnapshot0) + .getOrElse(repo.repo(isSnapshot0)) + val upload = - if (repo.root.startsWith("http://") || repo.root.startsWith("https://")) + if (retainedRepo.root.startsWith("http://") || retainedRepo.root.startsWith("https://")) HttpURLConnectionUpload.create() else - FileUpload(Paths.get(new URI(repo.root))) + FileUpload(Paths.get(new URI(retainedRepo.root))) val dummy = false val isLocal = true val uploadLogger = InteractiveUploadLogger.create(System.err, dummy = dummy, isLocal = isLocal) val errors = - upload.uploadFileSet(repo, finalFileSet, uploadLogger, Some(ec)).unsafeRun()(ec) + upload.uploadFileSet(retainedRepo, finalFileSet, uploadLogger, Some(ec)).unsafeRun()(ec) errors.toList match { case h :: t => value(Left(new UploadError(::(h, t)))) case Nil => + hooks.afterUpload(hooksData).unsafeRun()(ec) } } } diff --git a/modules/cli/src/main/scala/scala/cli/commands/publish/RepositoryParser.scala b/modules/cli/src/main/scala/scala/cli/commands/publish/RepositoryParser.scala new file mode 100644 index 0000000000..85c6d9f412 --- /dev/null +++ b/modules/cli/src/main/scala/scala/cli/commands/publish/RepositoryParser.scala @@ -0,0 +1,76 @@ +package scala.cli.commands.publish + +// from coursier.internal.SharedRepositoryParser +// delete when coursier.internal.SharedRepositoryParser.repositoryOpt is available for us + +import coursier.Repositories +import coursier.core.Repository +import coursier.ivy.IvyRepository +import coursier.maven.MavenRepository + +object RepositoryParser { + + def repositoryOpt(s: String): Option[Repository] = + if (s == "central") + Some(Repositories.central) + else if (s.startsWith("sonatype:")) + Some(Repositories.sonatype(s.stripPrefix("sonatype:"))) + else if (s.startsWith("bintray:")) { + val s0 = s.stripPrefix("bintray:") + val id = + if (s.contains("/")) s0 + else s0 + "/maven" + + Some(Repositories.bintray(id)) + } + else if (s.startsWith("bintray-ivy:")) + Some(Repositories.bintrayIvy(s.stripPrefix("bintray-ivy:"))) + else if (s.startsWith("typesafe:ivy-")) + Some(Repositories.typesafeIvy(s.stripPrefix("typesafe:ivy-"))) + else if (s.startsWith("typesafe:")) + Some(Repositories.typesafe(s.stripPrefix("typesafe:"))) + else if (s.startsWith("sbt-maven:")) + Some(Repositories.sbtMaven(s.stripPrefix("sbt-maven:"))) + else if (s.startsWith("sbt-plugin:")) + Some(Repositories.sbtPlugin(s.stripPrefix("sbt-plugin:"))) + else if (s == "scala-integration" || s == "scala-nightlies") + Some(Repositories.scalaIntegration) + else if (s == "jitpack") + Some(Repositories.jitpack) + else if (s == "clojars") + Some(Repositories.clojars) + else if (s == "jcenter") + Some(Repositories.jcenter) + else if (s == "google") + Some(Repositories.google) + else if (s == "gcs") + Some(Repositories.centralGcs) + else if (s == "gcs-eu") + Some(Repositories.centralGcsEu) + else if (s == "gcs-asia") + Some(Repositories.centralGcsAsia) + else if (s.startsWith("apache:")) + Some(Repositories.apache(s.stripPrefix("apache:"))) + else + None + + def repository(s: String): Either[String, Repository] = + repositoryOpt(s) match { + case Some(repo) => Right(repo) + case None => + if (s.startsWith("ivy:")) { + val s0 = s.stripPrefix("ivy:") + val sepIdx = s0.indexOf('|') + if (sepIdx < 0) + IvyRepository.parse(s0) + else { + val mainPart = s0.substring(0, sepIdx) + val metadataPart = s0.substring(sepIdx + 1) + IvyRepository.parse(mainPart, Some(metadataPart)) + } + } + else + Right(MavenRepository(s)) + } + +} diff --git a/modules/directives/src/main/scala/scala/build/preprocessing/directives/UsingPublishDirectiveHandler.scala b/modules/directives/src/main/scala/scala/build/preprocessing/directives/UsingPublishDirectiveHandler.scala index 12589caf08..e8261c176a 100644 --- a/modules/directives/src/main/scala/scala/build/preprocessing/directives/UsingPublishDirectiveHandler.scala +++ b/modules/directives/src/main/scala/scala/build/preprocessing/directives/UsingPublishDirectiveHandler.scala @@ -53,7 +53,9 @@ case object UsingPublishDirectiveHandler extends UsingDirectiveHandler { "gpgOptions", "gpg-options", "secretKey", - "secretKeyPassword" + "secretKeyPassword", + "user", + "password" ).map(prefix + _) override def getValueNumberBounds(key: String) = key match { @@ -134,6 +136,10 @@ case object UsingPublishDirectiveHandler extends UsingDirectiveHandler { PublishOptions(secretKey = Some(value(parsePasswordOption(singleValue.value)))) case "secretKeyPassword" => PublishOptions(secretKeyPassword = Some(value(parsePasswordOption(singleValue.value)))) + case "user" => + PublishOptions(repoUser = Some(value(parsePasswordOption(singleValue.value)))) + case "password" => + PublishOptions(repoPassword = Some(value(parsePasswordOption(singleValue.value)))) case _ => value(Left(new UnexpectedDirectiveError(scopedDirective.directive.key))) } diff --git a/modules/options/src/main/scala/scala/build/options/PublishOptions.scala b/modules/options/src/main/scala/scala/build/options/PublishOptions.scala index f0aa83db2e..46b9c48939 100644 --- a/modules/options/src/main/scala/scala/build/options/PublishOptions.scala +++ b/modules/options/src/main/scala/scala/build/options/PublishOptions.scala @@ -24,6 +24,8 @@ final case class PublishOptions( signer: Option[Signer] = None, secretKey: Option[PasswordOption] = None, secretKeyPassword: Option[PasswordOption] = None, + repoUser: Option[PasswordOption] = None, + repoPassword: Option[PasswordOption] = None, computeVersion: Option[ComputeVersion] = None ) diff --git a/website/docs/reference/cli-options.md b/website/docs/reference/cli-options.md index b638a083d3..71a3e06cd1 100644 --- a/website/docs/reference/cli-options.md +++ b/website/docs/reference/cli-options.md @@ -1003,6 +1003,14 @@ Aliases: `-R`, `--publish-repo` Repository to publish to +#### `--user` + +User to use with publishing repository + +#### `--password` + +Password to use with publishing repository + #### `--secret-key` Secret key to use to sign artifacts with BouncyCastle