mirror of https://github.com/sbt/sbt.git
[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:
parent
5ee54ac60c
commit
b83eee4528
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue