[sonatype publishing] print deployment validation errors if present

Before this change you had to log into the sonatype account and search for the errors there.
(https://central.sonatype.com/publishing/deployments)
This was inconvenient, especially if you don't have the admin access to the account.
This commit is contained in:
Dmitrii Naumenko 2025-08-04 11:52:32 +02:00
parent 5ee54ac60c
commit b83eee4528
7 changed files with 275 additions and 12 deletions

View File

@ -9,22 +9,23 @@ final class PublisherStatus private (
val deploymentId: String, val deploymentId: String,
val deploymentName: String, val deploymentName: String,
val deploymentState: sbt.internal.sona.DeploymentState, val deploymentState: sbt.internal.sona.DeploymentState,
val purls: Vector[String]) extends Serializable { val purls: Vector[String],
val errors: Option[sjsonnew.shaded.scalajson.ast.unsafe.JValue]) extends Serializable {
override def equals(o: Any): Boolean = this.eq(o.asInstanceOf[AnyRef]) || (o match { override def equals(o: Any): Boolean = this.eq(o.asInstanceOf[AnyRef]) || (o match {
case x: PublisherStatus => (this.deploymentId == x.deploymentId) && (this.deploymentName == x.deploymentName) && (this.deploymentState == x.deploymentState) && (this.purls == x.purls) case x: PublisherStatus => (this.deploymentId == x.deploymentId) && (this.deploymentName == x.deploymentName) && (this.deploymentState == x.deploymentState) && (this.purls == x.purls) && (this.errors == x.errors)
case _ => false case _ => false
}) })
override def hashCode: Int = { override def hashCode: Int = {
37 * (37 * (37 * (37 * (37 * (17 + "sbt.internal.sona.PublisherStatus".##) + deploymentId.##) + deploymentName.##) + deploymentState.##) + purls.##) 37 * (37 * (37 * (37 * (37 * (37 * (17 + "sbt.internal.sona.PublisherStatus".##) + deploymentId.##) + deploymentName.##) + deploymentState.##) + purls.##) + errors.##)
} }
override def toString: String = { override def toString: String = {
"PublisherStatus(" + deploymentId + ", " + deploymentName + ", " + deploymentState + ", " + purls + ")" "PublisherStatus(" + deploymentId + ", " + deploymentName + ", " + deploymentState + ", " + purls + ", " + errors + ")"
} }
private[this] def copy(deploymentId: String = deploymentId, deploymentName: String = deploymentName, deploymentState: sbt.internal.sona.DeploymentState = deploymentState, purls: Vector[String] = purls): PublisherStatus = { private[this] def copy(deploymentId: String = deploymentId, deploymentName: String = deploymentName, deploymentState: sbt.internal.sona.DeploymentState = deploymentState, purls: Vector[String] = purls, errors: Option[sjsonnew.shaded.scalajson.ast.unsafe.JValue] = errors): PublisherStatus = {
new PublisherStatus(deploymentId, deploymentName, deploymentState, purls) new PublisherStatus(deploymentId, deploymentName, deploymentState, purls, errors)
} }
def withDeploymentId(deploymentId: String): PublisherStatus = { def withDeploymentId(deploymentId: String): PublisherStatus = {
copy(deploymentId = deploymentId) copy(deploymentId = deploymentId)
@ -38,8 +39,15 @@ final class PublisherStatus private (
def withPurls(purls: Vector[String]): PublisherStatus = { def withPurls(purls: Vector[String]): PublisherStatus = {
copy(purls = purls) copy(purls = purls)
} }
def withErrors(errors: Option[sjsonnew.shaded.scalajson.ast.unsafe.JValue]): PublisherStatus = {
copy(errors = errors)
}
def withErrors(errors: sjsonnew.shaded.scalajson.ast.unsafe.JValue): PublisherStatus = {
copy(errors = Option(errors))
}
} }
object PublisherStatus { object PublisherStatus {
def apply(deploymentId: String, deploymentName: String, deploymentState: sbt.internal.sona.DeploymentState, purls: Vector[String]): PublisherStatus = new PublisherStatus(deploymentId, deploymentName, deploymentState, purls) def apply(deploymentId: String, deploymentName: String, deploymentState: sbt.internal.sona.DeploymentState, purls: Vector[String], errors: Option[sjsonnew.shaded.scalajson.ast.unsafe.JValue]): PublisherStatus = new PublisherStatus(deploymentId, deploymentName, deploymentState, purls, errors)
def apply(deploymentId: String, deploymentName: String, deploymentState: sbt.internal.sona.DeploymentState, purls: Vector[String], errors: sjsonnew.shaded.scalajson.ast.unsafe.JValue): PublisherStatus = new PublisherStatus(deploymentId, deploymentName, deploymentState, purls, Option(errors))
} }

View File

@ -6,5 +6,6 @@
package sbt.internal.sona.codec package sbt.internal.sona.codec
trait JsonProtocol extends sjsonnew.BasicJsonProtocol trait JsonProtocol extends sjsonnew.BasicJsonProtocol
with sbt.internal.sona.codec.DeploymentStateFormats with sbt.internal.sona.codec.DeploymentStateFormats
with sbt.internal.util.codec.JValueFormats
with sbt.internal.sona.codec.PublisherStatusFormats with sbt.internal.sona.codec.PublisherStatusFormats
object JsonProtocol extends JsonProtocol object JsonProtocol extends JsonProtocol

View File

@ -5,7 +5,7 @@
// DO NOT EDIT MANUALLY // DO NOT EDIT MANUALLY
package sbt.internal.sona.codec package sbt.internal.sona.codec
import _root_.sjsonnew.{ Unbuilder, Builder, JsonFormat, deserializationError } import _root_.sjsonnew.{ Unbuilder, Builder, JsonFormat, deserializationError }
trait PublisherStatusFormats { self: sbt.internal.sona.codec.DeploymentStateFormats with sjsonnew.BasicJsonProtocol => trait PublisherStatusFormats { self: sbt.internal.sona.codec.DeploymentStateFormats with sjsonnew.BasicJsonProtocol with sbt.internal.util.codec.JValueFormats =>
implicit lazy val PublisherStatusFormat: JsonFormat[sbt.internal.sona.PublisherStatus] = new JsonFormat[sbt.internal.sona.PublisherStatus] { implicit lazy val PublisherStatusFormat: JsonFormat[sbt.internal.sona.PublisherStatus] = new JsonFormat[sbt.internal.sona.PublisherStatus] {
override def read[J](__jsOpt: Option[J], unbuilder: Unbuilder[J]): sbt.internal.sona.PublisherStatus = { override def read[J](__jsOpt: Option[J], unbuilder: Unbuilder[J]): sbt.internal.sona.PublisherStatus = {
__jsOpt match { __jsOpt match {
@ -15,8 +15,9 @@ implicit lazy val PublisherStatusFormat: JsonFormat[sbt.internal.sona.PublisherS
val deploymentName = unbuilder.readField[String]("deploymentName") val deploymentName = unbuilder.readField[String]("deploymentName")
val deploymentState = unbuilder.readField[sbt.internal.sona.DeploymentState]("deploymentState") val deploymentState = unbuilder.readField[sbt.internal.sona.DeploymentState]("deploymentState")
val purls = unbuilder.readField[Vector[String]]("purls") val purls = unbuilder.readField[Vector[String]]("purls")
val errors = unbuilder.readField[Option[sjsonnew.shaded.scalajson.ast.unsafe.JValue]]("errors")
unbuilder.endObject() unbuilder.endObject()
sbt.internal.sona.PublisherStatus(deploymentId, deploymentName, deploymentState, purls) sbt.internal.sona.PublisherStatus(deploymentId, deploymentName, deploymentState, purls, errors)
case None => case None =>
deserializationError("Expected JsObject but found None") deserializationError("Expected JsObject but found None")
} }
@ -27,6 +28,7 @@ implicit lazy val PublisherStatusFormat: JsonFormat[sbt.internal.sona.PublisherS
builder.addField("deploymentName", obj.deploymentName) builder.addField("deploymentName", obj.deploymentName)
builder.addField("deploymentState", obj.deploymentState) builder.addField("deploymentState", obj.deploymentState)
builder.addField("purls", obj.purls) builder.addField("purls", obj.purls)
builder.addField("errors", obj.errors)
builder.endObject() builder.endObject()
} }
} }

View File

@ -18,4 +18,6 @@ type PublisherStatus {
deploymentName: String! deploymentName: String!
deploymentState: sbt.internal.sona.DeploymentState! deploymentState: sbt.internal.sona.DeploymentState!
purls: [String] purls: [String]
} # Optional errors. The field has non-standard structure and thus we avoid automatic format generation
errors: sjsonnew.shaded.scalajson.ast.unsafe.JValue
}

View File

@ -0,0 +1,74 @@
/*
* sbt
* Copyright 2023, Scala center
* Copyright 2011 - 2022, Lightbend, Inc.
* Copyright 2008 - 2010, Mark Harrah
* Licensed under Apache License 2.0 (see LICENSE)
*/
package sbt.internal.sona
import sjsonnew.shaded.scalajson.ast.unsafe.*
/**
* Represents validation errors for one of the deployed packages in case deployment to sonatype has failed
*
* @param packageDescriptor package descriptor<br>
* (e.g. "pkg:maven/org.example.company/sbt-plugin-core_2.12_1.0@0.0.6")
* @param packageErrors list of validation errors for the package<br>
* (e.g. ""Component with package url: 'pkg:maven/org.example.company/sbt-plugin-core_2.12_1.0@0.0.6' already exists")
* @see https://central.sonatype.org/publish/publish-portal-api/#verify-status-of-the-deployment
*/
private case class PackageDeploymentValidationError(
packageDescriptor: String,
packageErrors: Seq[String]
)
private object PackageDeploymentValidationError {
/**
* Example: (it's not an array but an object which makes it hard to parse with the standard contraband means)
* {{{
* {
* <OTHER_FIELDS>,
* "errors": {
* "pkg:maven/org.example.company/sbt-plugin-core_2.12_1.0@0.0.6": [
* "Component with package url: 'pkg:maven/org.example.company/sbt-plugin-core_2.12_1.0@0.0.6' already exists"
* ],
* "pkg:maven/org.example.company/sbt-plugin-extra_2.12_1.0@0.0.6": [
* "Component with package url: 'pkg:maven/org.example.company/sbt-plugin-extra_2.12_1.0@0.0.6' already exists"
* ]
* }
* }
* }}}
*
* @param errorsNode - the JSON node that contains the errors
* @return Some(errors) - if the JSON structure matches our expectations<br>
* None - otherwise (Sonatype Central could change the format of the output)
*/
def parse(errorsNode: JValue): Option[Seq[PackageDeploymentValidationError]] =
errorsNode match {
case JObject(fields) =>
val errors = fields.toSeq.flatMap {
case JField(packageInfo, JArray(packageErrors)) =>
val packageErrorsTexts = packageErrors.flatMap {
case JString(value) => Some(value)
case other => None
}
val noParsingIssues = packageErrors.length == packageErrorsTexts.length
if (noParsingIssues)
Some(PackageDeploymentValidationError(packageInfo, packageErrorsTexts))
else
None
case _ =>
None
}
val noParsingIssues = errors.size == fields.length
if (noParsingIssues)
Some(errors)
else
None
case _ =>
None
}
}

View File

@ -12,10 +12,11 @@ package sona
import gigahorse.* import gigahorse.*
import gigahorse.support.apachehttp.Gigahorse import gigahorse.support.apachehttp.Gigahorse
import sbt.internal.sona.SonaClient.failedDeploymentErrorText
import sbt.util.Logger import sbt.util.Logger
import sjsonnew.JsonFormat import sjsonnew.JsonFormat
import sjsonnew.shaded.scalajson.ast.unsafe.JValue import sjsonnew.shaded.scalajson.ast.unsafe.JValue
import sjsonnew.support.scalajson.unsafe.{ Converter, Parser } import sjsonnew.support.scalajson.unsafe.{ Converter, Parser, PrettyPrinter }
import java.net.URLEncoder import java.net.URLEncoder
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
@ -107,7 +108,9 @@ class SonaClient(reqTransform: Request => Request, uploadRequestTimeout: FiniteD
if (attempt <= 3) List(5, 5, 10, 15)(attempt) if (attempt <= 3) List(5, 5, 10, 15)(attempt)
else 30 else 30
status.deploymentState match { status.deploymentState match {
case DeploymentState.FAILED => sys.error(s"deployment $deploymentId failed") case DeploymentState.FAILED =>
val errorText = failedDeploymentErrorText(deploymentId, status.errors, log)
sys.error(errorText)
case DeploymentState.PENDING | DeploymentState.PUBLISHING | DeploymentState.VALIDATING => case DeploymentState.PENDING | DeploymentState.PUBLISHING | DeploymentState.VALIDATING =>
Thread.sleep(sleepSec * 1000L) Thread.sleep(sleepSec * 1000L)
waitForDeploy(deploymentId, deploymentName, publishingType, attempt + 1, log) waitForDeploy(deploymentId, deploymentName, publishingType, attempt + 1, log)
@ -204,6 +207,54 @@ object SonaClient {
uploadRequestTimeout: FiniteDuration uploadRequestTimeout: FiniteDuration
): SonaClient = ): SonaClient =
new SonaClient(OAuthClient(userName, userToken), uploadRequestTimeout) new SonaClient(OAuthClient(userName, userToken), uploadRequestTimeout)
/**
* @note non-private visibility only for the tests
*/
private[sona] def failedDeploymentErrorText(
deploymentId: String,
errors: Option[JValue],
log: Logger
): String = {
val errorsText = errors.map(presentDeploymentValidationErrors(_, log))
val errorsMessagePart = errorsText match {
case Some(value) =>
s" with validation errors:\n$value"
case None => ""
}
s"deployment $deploymentId failed$errorsMessagePart"
}
import sbt.internal.sona.SonaClient.PrettyPrint.*
private def presentDeploymentValidationErrors(errorsNode: JValue, log: Logger): String = {
PackageDeploymentValidationError.parse(errorsNode) match {
case Some(errors) =>
val errorsPresented: Seq[String] = errors.map {
case PackageDeploymentValidationError(packageDescriptor, packageErrors) =>
s"""$packageDescriptor
|${indent(asList(packageErrors), 2)}""".stripMargin
}
indent(asList(errorsPresented), 2)
case None =>
// Sonatype might change the format of the errors in the future.
// We shouldn't fail, and as a fallback we pretty print the JSON representation
log.warn(
"Sonatype deployment validation errors JSON format has changed. Please update to the latest sbt version or report the issue to the sbt project"
)
PrettyPrinter(errorsNode)
}
}
private object PrettyPrint {
def asList(lines: Seq[String]): String =
lines.map("- " + _).mkString("\n")
def indent(text: String, indentSize: Int): String = {
val indent = " " * indentSize
text.linesIterator.map(indent + _).mkString("\n")
}
}
} }
private case class OAuthClient(userName: String, userToken: String) private case class OAuthClient(userName: String, userToken: String)

View File

@ -0,0 +1,125 @@
/*
* sbt
* Copyright 2023, Scala center
* Copyright 2011 - 2022, Lightbend, Inc.
* Copyright 2008 - 2010, Mark Harrah
* Licensed under Apache License 2.0 (see LICENSE)
*/
package sbt.internal.sona
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers.convertToAnyShouldWrapper
import sbt.internal.sona.SonaClientTest.RecordingLogger
import sbt.internal.util.BasicLogger
import sbt.util.*
import sjsonnew.support.scalajson.unsafe.Parser
import scala.collection.immutable
class SonaClientTest extends AnyFlatSpec {
private def doTest(
errorsJsonText: Option[String],
expectedErrorMessage: String,
expectedLogText: String = ""
): Unit = {
val logger = new RecordingLogger()
val errorsNode = errorsJsonText.map(Parser.parseUnsafe)
val result = SonaClient.failedDeploymentErrorText(
deploymentId = "12345",
errors = errorsNode,
log = logger
)
result shouldBe expectedErrorMessage
val actualLogText = logger.getLogMessages.mkString("\n")
actualLogText shouldBe expectedLogText
() //to avoid the "discarded non-Unit" value warning
}
it should "construct a failed deployment error message without errors" in doTest(
None,
"""deployment 12345 failed""".stripMargin
)
it should "construct a failed deployment error message with validation errors" in doTest(
Some(
"""{
| "pkg:maven/org.example.company/sbt-plugin-core_2.12_1.0@0.0.6": [
| "Component with package url: 'pkg:maven/org.example.company/sbt-plugin-core_2.12_1.0@0.0.6' already exists"
| ],
| "pkg:maven/org.example.company/sbt-plugin-extra_2.12_1.0@0.0.6": [
| "Component with package url: 'pkg:maven/org.example.company/sbt-plugin-extra_2.12_1.0@0.0.6' some reason 1",
| "Component with package url: 'pkg:maven/org.example.company/sbt-plugin-extra_2.12_1.0@0.0.6' some reason 2"
| ]
|}""".stripMargin
),
"""deployment 12345 failed with validation errors:
| - pkg:maven/org.example.company/sbt-plugin-core_2.12_1.0@0.0.6
| - Component with package url: 'pkg:maven/org.example.company/sbt-plugin-core_2.12_1.0@0.0.6' already exists
| - pkg:maven/org.example.company/sbt-plugin-extra_2.12_1.0@0.0.6
| - Component with package url: 'pkg:maven/org.example.company/sbt-plugin-extra_2.12_1.0@0.0.6' some reason 1
| - Component with package url: 'pkg:maven/org.example.company/sbt-plugin-extra_2.12_1.0@0.0.6' some reason 2""".stripMargin
)
it should "construct a failed deployment error message with validation errors in an unknown format" in doTest(
Some(
"""[
| {
| "package" : "pkg:maven/org.example.company/sbt-plugin-core_2.12_1.0@0.0.6",
| "errors" : [
| "Component with package url: 'pkg:maven/org.example.company/sbt-plugin-core_2.12_1.0@0.0.6' already exists"
| ]
| },
| {
| "package" : "pkg:maven/org.example.company/sbt-plugin-extra_2.12_1.0@0.0.6",
| "errors" : [
| "Component with package url: 'pkg:maven/org.example.company/sbt-plugin-extra_2.12_1.0@0.0.6' some reason 1",
| "Component with package url: 'pkg:maven/org.example.company/sbt-plugin-extra_2.12_1.0@0.0.6' some reason 2"
| ]
| }
|]""".stripMargin
),
"""deployment 12345 failed with validation errors:
|[{
| "package": "pkg:maven/org.example.company/sbt-plugin-core_2.12_1.0@0.0.6",
| "errors": ["Component with package url: 'pkg:maven/org.example.company/sbt-plugin-core_2.12_1.0@0.0.6' already exists"]
|}, {
| "package": "pkg:maven/org.example.company/sbt-plugin-extra_2.12_1.0@0.0.6",
| "errors": ["Component with package url: 'pkg:maven/org.example.company/sbt-plugin-extra_2.12_1.0@0.0.6' some reason 1", "Component with package url: 'pkg:maven/org.example.company/sbt-plugin-extra_2.12_1.0@0.0.6' some reason 2"]
|}]""".stripMargin,
expectedLogText =
"[warn] Sonatype deployment validation errors JSON format has changed. Please update to the latest sbt version or report the issue to the sbt project"
)
}
object SonaClientTest {
implicit class RecordingLoggerOps(private val value: RecordingLogger) extends AnyVal {
def getLogMessages: immutable.Seq[String] =
value.getEvents.collect { case l: Log => s"[${l.level}] ${l.msg}" }
}
/**
* Records logging events for later retrieval.
*
* @note This is a copy of a logger from the "util-logging" module tests.
* Instead of copying we could depend on the module test directly or extract it into some test-utilities module.
*/
final class RecordingLogger extends BasicLogger {
private var events: List[LogEvent] = Nil
def getEvents = events.reverse
override def ansiCodesSupported = true
def trace(t: => Throwable): Unit = { events ::= new Trace(t) }
def log(level: Level.Value, message: => String): Unit = { events ::= new Log(level, message) }
def success(message: => String): Unit = { events ::= new Success(message) }
def logAll(es: Seq[LogEvent]): Unit = { events :::= es.toList }
def control(event: ControlEvent.Value, message: => String): Unit =
events ::= new ControlEvent(event, message)
}
}