From b83eee4528d6014badbb1acbc2ba621bda71726a Mon Sep 17 00:00:00 2001 From: Dmitrii Naumenko Date: Mon, 4 Aug 2025 11:52:32 +0200 Subject: [PATCH] [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. --- .../sbt/internal/sona/PublisherStatus.scala | 22 ++- .../internal/sona/codec/JsonProtocol.scala | 1 + .../sona/codec/PublisherStatusFormats.scala | 6 +- main-actions/src/main/contraband/sona.contra | 4 +- .../PackageDeploymentValidationError.scala | 74 +++++++++++ .../main/scala/sbt/internal/sona/Sona.scala | 55 +++++++- .../sbt/internal/sona/SonaClientTest.scala | 125 ++++++++++++++++++ 7 files changed, 275 insertions(+), 12 deletions(-) create mode 100644 main-actions/src/main/scala/sbt/internal/sona/PackageDeploymentValidationError.scala create mode 100644 main-actions/src/test/scala/sbt/internal/sona/SonaClientTest.scala diff --git a/main-actions/src/main/contraband-scala/sbt/internal/sona/PublisherStatus.scala b/main-actions/src/main/contraband-scala/sbt/internal/sona/PublisherStatus.scala index 6e1a764b1..a22caa290 100644 --- a/main-actions/src/main/contraband-scala/sbt/internal/sona/PublisherStatus.scala +++ b/main-actions/src/main/contraband-scala/sbt/internal/sona/PublisherStatus.scala @@ -9,22 +9,23 @@ final class PublisherStatus private ( val deploymentId: String, val deploymentName: String, 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 { - 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 }) 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 = { - "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 = { - new PublisherStatus(deploymentId, deploymentName, deploymentState, purls) + 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, errors) } def withDeploymentId(deploymentId: String): PublisherStatus = { copy(deploymentId = deploymentId) @@ -38,8 +39,15 @@ final class PublisherStatus private ( def withPurls(purls: Vector[String]): PublisherStatus = { 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 { - 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)) } diff --git a/main-actions/src/main/contraband-scala/sbt/internal/sona/codec/JsonProtocol.scala b/main-actions/src/main/contraband-scala/sbt/internal/sona/codec/JsonProtocol.scala index 01f3409a1..64672519f 100644 --- a/main-actions/src/main/contraband-scala/sbt/internal/sona/codec/JsonProtocol.scala +++ b/main-actions/src/main/contraband-scala/sbt/internal/sona/codec/JsonProtocol.scala @@ -6,5 +6,6 @@ package sbt.internal.sona.codec trait JsonProtocol extends sjsonnew.BasicJsonProtocol with sbt.internal.sona.codec.DeploymentStateFormats + with sbt.internal.util.codec.JValueFormats with sbt.internal.sona.codec.PublisherStatusFormats object JsonProtocol extends JsonProtocol \ No newline at end of file diff --git a/main-actions/src/main/contraband-scala/sbt/internal/sona/codec/PublisherStatusFormats.scala b/main-actions/src/main/contraband-scala/sbt/internal/sona/codec/PublisherStatusFormats.scala index 523526990..ee86bfefb 100644 --- a/main-actions/src/main/contraband-scala/sbt/internal/sona/codec/PublisherStatusFormats.scala +++ b/main-actions/src/main/contraband-scala/sbt/internal/sona/codec/PublisherStatusFormats.scala @@ -5,7 +5,7 @@ // DO NOT EDIT MANUALLY package sbt.internal.sona.codec 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] { override def read[J](__jsOpt: Option[J], unbuilder: Unbuilder[J]): sbt.internal.sona.PublisherStatus = { __jsOpt match { @@ -15,8 +15,9 @@ implicit lazy val PublisherStatusFormat: JsonFormat[sbt.internal.sona.PublisherS val deploymentName = unbuilder.readField[String]("deploymentName") val deploymentState = unbuilder.readField[sbt.internal.sona.DeploymentState]("deploymentState") val purls = unbuilder.readField[Vector[String]]("purls") + val errors = unbuilder.readField[Option[sjsonnew.shaded.scalajson.ast.unsafe.JValue]]("errors") unbuilder.endObject() - sbt.internal.sona.PublisherStatus(deploymentId, deploymentName, deploymentState, purls) + sbt.internal.sona.PublisherStatus(deploymentId, deploymentName, deploymentState, purls, errors) case 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("deploymentState", obj.deploymentState) builder.addField("purls", obj.purls) + builder.addField("errors", obj.errors) builder.endObject() } } diff --git a/main-actions/src/main/contraband/sona.contra b/main-actions/src/main/contraband/sona.contra index 1e4bdd90c..03c7a3af6 100644 --- a/main-actions/src/main/contraband/sona.contra +++ b/main-actions/src/main/contraband/sona.contra @@ -18,4 +18,6 @@ type PublisherStatus { deploymentName: String! deploymentState: sbt.internal.sona.DeploymentState! purls: [String] -} + # Optional errors. The field has non-standard structure and thus we avoid automatic format generation + errors: sjsonnew.shaded.scalajson.ast.unsafe.JValue +} \ No newline at end of file diff --git a/main-actions/src/main/scala/sbt/internal/sona/PackageDeploymentValidationError.scala b/main-actions/src/main/scala/sbt/internal/sona/PackageDeploymentValidationError.scala new file mode 100644 index 000000000..065ba88bf --- /dev/null +++ b/main-actions/src/main/scala/sbt/internal/sona/PackageDeploymentValidationError.scala @@ -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
+ * (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
+ * (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) + * {{{ + * { + * , + * "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
+ * 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 + } +} diff --git a/main-actions/src/main/scala/sbt/internal/sona/Sona.scala b/main-actions/src/main/scala/sbt/internal/sona/Sona.scala index 2536176c1..a0bec01bf 100644 --- a/main-actions/src/main/scala/sbt/internal/sona/Sona.scala +++ b/main-actions/src/main/scala/sbt/internal/sona/Sona.scala @@ -12,10 +12,11 @@ package sona import gigahorse.* import gigahorse.support.apachehttp.Gigahorse +import sbt.internal.sona.SonaClient.failedDeploymentErrorText import sbt.util.Logger import sjsonnew.JsonFormat 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.nio.charset.StandardCharsets @@ -107,7 +108,9 @@ class SonaClient(reqTransform: Request => Request, uploadRequestTimeout: FiniteD if (attempt <= 3) List(5, 5, 10, 15)(attempt) else 30 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 => Thread.sleep(sleepSec * 1000L) waitForDeploy(deploymentId, deploymentName, publishingType, attempt + 1, log) @@ -204,6 +207,54 @@ object SonaClient { uploadRequestTimeout: FiniteDuration ): SonaClient = 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) diff --git a/main-actions/src/test/scala/sbt/internal/sona/SonaClientTest.scala b/main-actions/src/test/scala/sbt/internal/sona/SonaClientTest.scala new file mode 100644 index 000000000..f45339a06 --- /dev/null +++ b/main-actions/src/test/scala/sbt/internal/sona/SonaClientTest.scala @@ -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) + } +}