Skip to content

publish command with Sonatype Central Portal OSSRH Staging API #3774

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -425,7 +425,7 @@ object Publish extends ScalaCommand[PublishOptions] with BuildCommandHelpers {
now: Instant,
isIvy2LocalLike: Boolean,
isCi: Boolean,
isLegacySonatype: Boolean,
isSonatype: Boolean,
withTestScope: Boolean,
logger: Logger
): Either[BuildException, (FileSet, (coursier.core.Module, String))] = either {
Expand Down Expand Up @@ -540,7 +540,7 @@ object Publish extends ScalaCommand[PublishOptions] with BuildCommandHelpers {
developers = developers
)

if isLegacySonatype then {
if isSonatype then {
if url.isEmpty then
logger.diagnostic(
"Publishing to Sonatype, but project URL is empty (set it with the '//> using publish.url' directive)."
Expand Down Expand Up @@ -750,7 +750,7 @@ object Publish extends ScalaCommand[PublishOptions] with BuildCommandHelpers {
now = now,
isIvy2LocalLike = repoParams.isIvy2LocalLike,
isCi = isCi,
isLegacySonatype = repoParams.isLegacySonatype,
isSonatype = repoParams.isSonatype,
withTestScope = withTestScope,
logger = logger
)
Expand Down Expand Up @@ -878,7 +878,7 @@ object Publish extends ScalaCommand[PublishOptions] with BuildCommandHelpers {
val isSnapshot0 = modVersionOpt.exists(_._2.endsWith("SNAPSHOT"))
val authOpt0 = value(authOpt(
repo = repoParams.repo.repo(isSnapshot0).root,
isLegacySonatype = repoParams.isLegacySonatype
isLegacySonatype = repoParams.isSonatype
))
val asciiRegex = """[\u0000-\u007f]*""".r
val usernameOnlyAscii = authOpt0.exists(auth => asciiRegex.matches(auth.user))
Expand All @@ -891,7 +891,7 @@ object Publish extends ScalaCommand[PublishOptions] with BuildCommandHelpers {
)
val repoParams0: RepoParams = repoParams.withAuth(authOpt0)
val isLegacySonatype =
repoParams0.isLegacySonatype && !repoParams0.repo.releaseRepo.root.contains("s01")
repoParams0.isSonatype && !repoParams0.repo.releaseRepo.root.contains("s01")
val hooksDataOpt = Option.when(!dummy) {
try repoParams0.hooks.beforeUpload(finalFileSet, isSnapshot0).unsafeRun()(using ec)
catch {
Expand Down Expand Up @@ -953,7 +953,7 @@ object Publish extends ScalaCommand[PublishOptions] with BuildCommandHelpers {

errors.toList match {
case (h @ (_, _, e: Upload.Error.HttpError)) :: _
if repoParams0.isLegacySonatype && errors.distinctBy(_._3.getMessage()).size == 1 =>
if repoParams0.isSonatype && errors.distinctBy(_._3.getMessage()).size == 1 =>
val httpCodeRegex = "HTTP (\\d+)\n.*".r
e.getMessage match {
case httpCodeRegex("403") =>
Expand All @@ -968,7 +968,7 @@ object Publish extends ScalaCommand[PublishOptions] with BuildCommandHelpers {
)
case _ => throw new UploadError(::(h, Nil))
}
case _ :: _ if repoParams0.isLegacySonatype && errors.forall {
case _ :: _ if repoParams0.isSonatype && errors.forall {
case (_, _, _: Upload.Error.Unauthorized) => true
case _ => false
} =>
Expand Down
183 changes: 112 additions & 71 deletions modules/cli/src/main/scala/scala/cli/commands/publish/RepoParams.scala
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import java.util.concurrent.ScheduledExecutorService
import scala.build.EitherCps.{either, value}
import scala.build.Logger
import scala.build.errors.BuildException
import scala.build.internals.ConsoleUtils.ScalaCliConsole.warnPrefix
import scala.cli.commands.util.ScalaCliSttpBackend

final case class RepoParams(
Expand All @@ -25,6 +26,8 @@ final case class RepoParams(
shouldSign: Boolean,
shouldAuthenticate: Boolean
) {
import RepoParams.*

def withAuth(auth: Authentication): RepoParams =
copy(
repo = repo.withAuthentication(auth),
Expand All @@ -41,14 +44,25 @@ final case class RepoParams(
)
def withAuth(authOpt: Option[Authentication]): RepoParams = authOpt.fold(this)(withAuth)

lazy val isLegacySonatype: Boolean =
lazy val isSonatype: Boolean =
Option(new URI(repo.snapshotRepo.root))
.filter(_.getScheme == "https")
.map(_.getHost)
.exists(host => host == "oss.sonatype.org" || host.endsWith(".oss.sonatype.org"))
.exists(sonatypeHosts.contains)
}

object RepoParams {
private val sonatypeOssrhStagingApiBase = "https://ossrh-staging-api.central.sonatype.com"
private val sonatypeSnapshotsBase = "https://central.sonatype.com/repository/maven-snapshots/"
private val sonatypeLegacyBase = "https://oss.sonatype.org"
private val sonatypeS01LegacyBase = "https://s01.oss.sonatype.org"
private def sonatypeHosts: Seq[String] =
Seq(
sonatypeLegacyBase,
sonatypeSnapshotsBase,
sonatypeS01LegacyBase,
sonatypeOssrhStagingApiBase
).map(new URI(_).getHost)

def apply(
repo: String,
Expand All @@ -67,24 +81,42 @@ object RepoParams {
case "ivy2-local" =>
RepoParams.ivy2Local(ivy2HomeOpt)
case "sonatype" | "central" | "maven-central" | "mvn-central" =>
logger.message(s"Using Portal OSSRH Staging API: $sonatypeOssrhStagingApiBase")
RepoParams.centralRepo(
base = sonatypeOssrhStagingApiBase,
useLegacySnapshots = false,
connectionTimeoutRetries = connectionTimeoutRetries,
connectionTimeoutSeconds = connectionTimeoutSeconds,
stagingRepoRetries = stagingRepoRetries,
stagingRepoWaitTimeMilis = stagingRepoWaitTimeMilis,
es = es,
logger = logger
)
case "sonatype-legacy" | "central-legacy" | "maven-central-legacy" | "mvn-central-legacy" =>
logger.message(s"$warnPrefix $sonatypeLegacyBase is EOL since 2025-06-30.")
logger.message(s"$warnPrefix $sonatypeLegacyBase publishing is expected to fail.")
RepoParams.centralRepo(
"https://oss.sonatype.org",
connectionTimeoutRetries,
connectionTimeoutSeconds,
stagingRepoRetries,
stagingRepoWaitTimeMilis,
es,
logger
base = sonatypeLegacyBase,
useLegacySnapshots = true,
connectionTimeoutRetries = connectionTimeoutRetries,
connectionTimeoutSeconds = connectionTimeoutSeconds,
stagingRepoRetries = stagingRepoRetries,
stagingRepoWaitTimeMilis = stagingRepoWaitTimeMilis,
es = es,
logger = logger
)
case "sonatype-s01" | "central-s01" | "maven-central-s01" | "mvn-central-s01" =>
logger.message(s"$warnPrefix $sonatypeS01LegacyBase is EOL since 2025-06-30.")
logger.message(s"$warnPrefix it's expected publishing will fail.")
RepoParams.centralRepo(
"https://s01.oss.sonatype.org",
connectionTimeoutRetries,
connectionTimeoutSeconds,
stagingRepoRetries,
stagingRepoWaitTimeMilis,
es,
logger
base = sonatypeS01LegacyBase,
useLegacySnapshots = true,
connectionTimeoutRetries = connectionTimeoutRetries,
connectionTimeoutSeconds = connectionTimeoutSeconds,
stagingRepoRetries = stagingRepoRetries,
stagingRepoWaitTimeMilis = stagingRepoWaitTimeMilis,
es = es,
logger = logger
)
case "github" =>
value(RepoParams.gitHubRepo(vcsUrlOpt, workspace, logger))
Expand All @@ -103,77 +135,86 @@ object RepoParams {
}

RepoParams(
PublishRepository.Simple(repo0),
None,
Hooks.dummy,
isIvy2LocalLike,
true,
true,
true,
false,
false
repo = PublishRepository.Simple(repo0),
targetRepoOpt = None,
hooks = Hooks.dummy,
isIvy2LocalLike = isIvy2LocalLike,
defaultParallelUpload = true,
supportsSig = true,
acceptsChecksums = true,
shouldSign = false,
shouldAuthenticate = false
)
}
}

def centralRepo(
base: String,
useLegacySnapshots: Boolean,
connectionTimeoutRetries: Option[Int],
connectionTimeoutSeconds: Option[Int],
stagingRepoRetries: Option[Int],
stagingRepoWaitTimeMilis: Option[Int],
es: ScheduledExecutorService,
logger: Logger
) = {
val repo0 = PublishRepository.Sonatype(MavenRepository(base))
): RepoParams = {
val repo0 = PublishRepository.Sonatype(
base = MavenRepository(base),
useLegacySnapshots = useLegacySnapshots
)
val backend = ScalaCliSttpBackend.httpURLConnection(logger, connectionTimeoutSeconds)
val api = SonatypeApi(
backend,
base + "/service/local",
None,
logger.verbosity,
backend = backend,
base = base + "/service/local",
authentication = None,
verbosity = logger.verbosity,
retryOnTimeout = connectionTimeoutRetries.getOrElse(3),
stagingRepoRetryParams = EmaRetryParams(
stagingRepoRetries.getOrElse(3),
stagingRepoWaitTimeMilis.getOrElse(10 * 1000),
2.0f
)
stagingRepoRetryParams =
EmaRetryParams(
attempts = stagingRepoRetries.getOrElse(3),
initialWaitDurationMs = stagingRepoWaitTimeMilis.getOrElse(10 * 1000),
factor = 2.0f
)
)
val hooks0 = Hooks.sonatype(
repo0,
api,
logger.compilerOutputStream, // meh
logger.verbosity,
repo = repo0,
api = api,
out = logger.compilerOutputStream, // meh
verbosity = logger.verbosity,
batch = coursier.paths.Util.useAnsiOutput(), // FIXME Get via logger
es
es = es
)
RepoParams(
repo0,
Some("https://repo1.maven.org/maven2"),
hooks0,
false,
true,
true,
true,
true,
true
repo = repo0,
targetRepoOpt = Some("https://repo1.maven.org/maven2"),
hooks = hooks0,
isIvy2LocalLike = false,
defaultParallelUpload = true,
supportsSig = true,
acceptsChecksums = true,
shouldSign = true,
shouldAuthenticate = true
)
}

def gitHubRepoFor(org: String, name: String) =
def gitHubRepoFor(org: String, name: String): RepoParams =
RepoParams(
PublishRepository.Simple(MavenRepository(s"https://maven.pkg.github.com/$org/$name")),
None,
Hooks.dummy,
false,
false,
false,
false,
false,
true
repo = PublishRepository.Simple(MavenRepository(s"https://maven.pkg.github.com/$org/$name")),
targetRepoOpt = None,
hooks = Hooks.dummy,
isIvy2LocalLike = false,
defaultParallelUpload = false,
supportsSig = false,
acceptsChecksums = false,
shouldSign = false,
shouldAuthenticate = true
)

def gitHubRepo(vcsUrlOpt: Option[String], workspace: os.Path, logger: Logger) = either {
def gitHubRepo(
vcsUrlOpt: Option[String],
workspace: os.Path,
logger: Logger
): Either[BuildException, RepoParams] = either {
val orgNameFromVcsOpt = vcsUrlOpt.flatMap(GitRepo.maybeGhOrgName)

val (org, name) = orgNameFromVcsOpt match {
Expand All @@ -184,23 +225,23 @@ object RepoParams {
gitHubRepoFor(org, name)
}

def ivy2Local(ivy2HomeOpt: Option[os.Path]) = {
def ivy2Local(ivy2HomeOpt: Option[os.Path]): RepoParams = {
val home = ivy2HomeOpt
.orElse(sys.props.get("ivy.home").map(prop => os.Path(prop)))
.orElse(sys.props.get("user.home").map(prop => os.Path(prop) / ".ivy2"))
.getOrElse(os.home / ".ivy2")
val base = home / "local"
// not really a Maven repo…
RepoParams(
PublishRepository.Simple(MavenRepository(base.toNIO.toUri.toASCIIString)),
None,
Hooks.dummy,
true,
true,
true,
true,
false,
false
repo = PublishRepository.Simple(MavenRepository(base.toNIO.toUri.toASCIIString)),
targetRepoOpt = None,
hooks = Hooks.dummy,
isIvy2LocalLike = true,
defaultParallelUpload = true,
supportsSig = true,
acceptsChecksums = true,
shouldSign = false,
shouldAuthenticate = false
)
}

Expand Down
2 changes: 1 addition & 1 deletion project/deps/package.mill.scala
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ object Deps {
def coursier = coursierDefault
def coursierCli = coursierDefault
def coursierM1Cli = coursierDefault
def coursierPublish = "0.3.0"
def coursierPublish = "0.4.0"
def jmh = "1.37"
def jsoniterScalaJava8 = "2.13.5.2"
def jsoup = "1.21.1"
Expand Down