Skip to content
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
4 changes: 2 additions & 2 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ lazy val core = (project in file("modules/core"))
// Docker image; host setup-sbt PATH is irrelevant. Docker is required when these tests run.
// Leave Testcontainers Ryuk enabled (do not set TESTCONTAINERS_RYUK_DISABLED): cleans up containers
// after aborted runs locally and is fine on GHA.
libraryDependencies ++= V.testcontainersDeps,
libraryDependencies ++= V.testcontainersDeps ++ V.deps(V.zioJson),
)

// Scala 3 compiler trees for catalog files. Not on a consumer's sbt session unless they load the plugin check
Expand Down Expand Up @@ -181,7 +181,7 @@ lazy val plugin = (project in file("modules/sbt-plugin"))
scalacOptions ++= V.commonScalacOptions,
publishMavenStyle := true,
pomIncludeRepository := { _ => false },
libraryDependencies ++= V.zioDeps,
libraryDependencies ++= V.zioDeps :+ V.moduleID(V.mimaCore),
testFrameworks += new TestFramework("zio.test.sbt.ZTestFramework"),
Test / mainClass := None,
// Bundle the remote-cache transport so consumers need one addSbtPlugin line. RemoteCachePlugin triggers on
Expand Down
32 changes: 20 additions & 12 deletions modules/core/src/main/scala/zipx/core/Advisory.scala
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package zipx.core

import java.net.URI
import java.time.Duration
import zio.json.*

enum AdvisorySeverity:
case Low, Moderate, High, Critical
Expand Down Expand Up @@ -42,21 +43,28 @@ end OsvAdvisorySource

object OsvAdvisorySource:

private final case class Query(`package`: Pkg, version: String) derives JsonEncoder
private final case class Pkg(purl: String) derives JsonEncoder
private final case class Response(vulns: Option[List[Vuln]]) derives JsonDecoder
private final case class Vuln(id: Option[String], summary: Option[String], severity: Option[String])
derives JsonDecoder

private[core] def queryBody(purl: Purl, version: String): String =
s"""{"package":{"purl":"${PinSnapshot.escape(purl)}"},"version":"${PinSnapshot.escape(version)}"}"""
Query(Pkg(purl), version).toJson

/** Pulls `id`, `summary`, and a severity token out of an OSV query response. Empty or missing `vulns` is no finding.
*/
private[core] def parseResponse(json: String): Either[String, List[Advisory]] =
MiniJson.extractArray(json, "vulns") match
case None => Right(Nil)
case Some(Left(err)) => Left(err)
case Some(Right(arr)) =>
Right(MiniJson.objects(arr).map { obj =>
val id = MiniJson.stringField(obj, "id").getOrElse("unknown")
val summary = MiniJson.stringField(obj, "summary").getOrElse("")
val severity =
MiniJson.stringField(obj, "severity").map(AdvisorySeverity.parse).getOrElse(AdvisorySeverity.Low)
Advisory(id, severity, summary)
})
json.fromJson[Response] match
case Left(err) => Left(s"osv: $err")
case Right(res) =>
Right(
res.vulns.getOrElse(Nil).map { v =>
Advisory(
id = v.id.getOrElse("unknown"),
severity = v.severity.map(AdvisorySeverity.parse).getOrElse(AdvisorySeverity.Low),
summary = v.summary.getOrElse(""),
)
}
)
end OsvAdvisorySource
28 changes: 28 additions & 0 deletions modules/core/src/main/scala/zipx/core/Capability.scala
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,8 @@ object Capability:
val FmtName: CapabilityName = CapabilityName("fmt")
val WorkflowCheckName: CapabilityName = CapabilityName("workflow-check")
val AdvisoriesName: CapabilityName = CapabilityName("advisories")
val ModverCheckName: CapabilityName = CapabilityName("modver-check")
val ModverSuggestName: CapabilityName = CapabilityName("modver-suggest")

private def testBody(scope: CapabilityScope, matrixed: Boolean): Capability = Capability(
name = TestName,
Expand Down Expand Up @@ -452,6 +454,32 @@ object Capability:
env = Map(PinCheck.BaseShaEnv -> EnvValue.typed(Expr.github("event.pull_request.base.sha"))),
)

def modverCheck(command: SbtCommand = SbtCommand.unsafeTask("zipxModverCheck")): Capability =
Capability.once(
name = ModverCheckName,
command = command,
phase = Phase.Verify,
gate = Gate.Always,
needsCapabilities = Nil,
permissions = Map("contents" -> "read"),
extraSteps = ModverCheck.fetchBaseSha,
condition = Some(JobCondition.eventIs("pull_request")),
env = Map(ModverCheck.BaseShaEnv -> EnvValue.typed(Expr.github("event.pull_request.base.sha"))),
)

def modverSuggest(command: SbtCommand = SbtCommand.unsafeTask("zipxModverSuggest")): Capability =
Capability.once(
name = ModverSuggestName,
command = command,
phase = Phase.Verify,
gate = Gate.Always,
needsCapabilities = Nil,
permissions = Map("contents" -> "read", "pull-requests" -> "write"),
extraSteps = ModverCheck.fetchBaseSha,
condition = Some(JobCondition.eventIs("pull_request")),
env = Map(ModverCheck.BaseShaEnv -> EnvValue.typed(Expr.github("event.pull_request.base.sha"))),
)

/** A Verify Once job that prints `zipx: skipping <gate>: <reason>` and exits 0. The check name stays on the PR. */
def skipOnce(name: CapabilityName, gate: String, reason: String): Capability =
Capability.steps(
Expand Down
115 changes: 63 additions & 52 deletions modules/core/src/main/scala/zipx/core/GitHubActionMeta.scala
Original file line number Diff line number Diff line change
@@ -1,76 +1,87 @@
package zipx.core

import zio.json.*

/** Parse GitHub releases/tags/git-ref JSON for [[Action]] bumps. HTTP lives in [[GitHubActionLookup]]. */
object GitHubActionMeta:

final case class Release(tag: String, sha: Option[String])

private final case class GhRelease(tag_name: String, draft: Option[Boolean], prerelease: Option[Boolean])
derives JsonDecoder
private final case class GhCommit(sha: Option[String]) derives JsonDecoder
private final case class GhTag(name: String, commit: Option[GhCommit]) derives JsonDecoder
private final case class GhGitObject(`type`: Option[String], sha: Option[String]) derives JsonDecoder
private final case class GhGitRef(`object`: Option[GhGitObject], sha: Option[String], `type`: Option[String])
derives JsonDecoder

def pickLatestRelease(json: String): Either[String, Option[Release]] =
val items = MiniJson.objects(if json.trim.startsWith("[") then json else s"[$json]")
val tags = items
.filter { obj =>
!MiniJson.boolField(obj, "draft").contains(true) &&
!MiniJson.boolField(obj, "prerelease").contains(true)
}
.flatMap(obj => MiniJson.stringField(obj, "tag_name"))
.filterNot(isPrereleaseTag)
Right(VersionStrategy.npm.latestStable(tags).orElse(tags.headOption).map(tag => Release(tag, None)))
val wrapped = if json.trim.startsWith("[") then json else s"[$json]"
wrapped.fromJson[List[GhRelease]] match
case Left(err) => Left(s"github releases: $err")
case Right(items) =>
val tags = items
.filter(r => !r.draft.contains(true) && !r.prerelease.contains(true))
.map(_.tag_name)
.filterNot(isPrereleaseTag)
Right(VersionStrategy.npm.latestStable(tags).orElse(tags.headOption).map(tag => Release(tag, None)))
end pickLatestRelease

def pickLatestTag(json: String): Either[String, Option[Release]] =
val items = MiniJson.objects(if json.trim.startsWith("[") then json else s"[$json]")
val tags = items.flatMap { obj =>
MiniJson.stringField(obj, "name").filterNot(isPrereleaseTag).map { tag =>
val sha = MiniJson.objectField(obj, "commit").flatMap(MiniJson.stringField(_, "sha"))
tag -> sha
}
}
Right(
VersionStrategy.npm
.latestStable(tags.map(_._1))
.orElse(tags.headOption.map(_._1))
.flatMap(latest => tags.find(_._1 == latest).map { case (tag, sha) => Release(tag, sha) })
)
val wrapped = if json.trim.startsWith("[") then json else s"[$json]"
wrapped.fromJson[List[GhTag]] match
case Left(err) => Left(s"github tags: $err")
case Right(items) =>
val tags = items.flatMap { t =>
Option.when(!isPrereleaseTag(t.name))(t.name -> t.commit.flatMap(_.sha))
}
Right(
VersionStrategy.npm
.latestStable(tags.map(_._1))
.orElse(tags.headOption.map(_._1))
.flatMap(latest => tags.find(_._1 == latest).map { case (tag, sha) => Release(tag, sha) })
)
end match
end pickLatestTag

def peelSha(refJson: String, tagObjectJson: Option[String] = None): Either[String, String] =
val obj = MiniJson.objectField(refJson, "object").getOrElse(refJson)
val sha = MiniJson.stringField(obj, "sha")
val tpe = MiniJson.stringField(obj, "type")
(sha, tpe) match
case (Some(s), Some("commit")) if GitSha.make(s).isRight => Right(s)
case (Some(_), Some("tag")) =>
tagObjectJson match
case None => Left("annotated tag needs a git/tags object to peel to a commit SHA")
case Some(body) =>
MiniJson
.objectField(body, "object")
.flatMap(MiniJson.stringField(_, "sha"))
.orElse(
MiniJson.stringField(body, "sha")
) match
case Some(s) if GitSha.make(s).isRight => Right(s)
case Some(s) => Left(s"peeled tag object is not a 40-hex SHA: $s")
case None => Left("git/tags object has no sha")
case (Some(s), _) if GitSha.make(s).isRight => Right(s)
case (Some(s), _) => Left(s"git ref is not a 40-hex SHA: $s")
case _ => Left("git ref JSON has no object.sha")
end match
gitObject(refJson).flatMap { obj =>
(obj.sha, obj.`type`) match
case (Some(s), Some("commit")) if GitSha.make(s).isRight => Right(s)
case (Some(_), Some("tag")) =>
tagObjectJson match
case None => Left("annotated tag needs a git/tags object to peel to a commit SHA")
case Some(body) =>
gitObject(body).flatMap { peeled =>
peeled.sha match
case Some(s) if GitSha.make(s).isRight => Right(s)
case Some(s) => Left(s"peeled tag object is not a 40-hex SHA: $s")
case None => Left("git/tags object has no sha")
}
case (Some(s), _) if GitSha.make(s).isRight => Right(s)
case (Some(s), _) => Left(s"git ref is not a 40-hex SHA: $s")
case _ => Left("git ref JSON has no object.sha")
}
end peelSha

private def gitObject(json: String): Either[String, GhGitObject] =
json.fromJson[GhGitRef] match
case Left(err) => Left(s"git ref JSON: $err")
case Right(r) => Right(r.`object`.getOrElse(GhGitObject(r.`type`, r.sha)))

def isPrereleaseTag(tag: String): Boolean =
val t = tag.stripPrefix("v").toLowerCase
t.contains("rc") || t.contains("alpha") || t.contains("beta") || t.contains("milestone") || t.contains("-m")

def peelFromRef(refJson: String, loadTagObject: String => Either[String, String]): Either[String, String] =
val obj = MiniJson.objectField(refJson, "object").getOrElse(refJson)
MiniJson.stringField(obj, "type") match
case Some("tag") =>
MiniJson.stringField(obj, "sha") match
case None => Left("annotated tag git ref has no sha")
case Some(tagSha) =>
loadTagObject(tagSha).flatMap(body => peelSha(refJson, Some(body)))
case _ => peelSha(refJson, None)
gitObject(refJson).flatMap { obj =>
obj.`type` match
case Some("tag") =>
obj.sha match
case None => Left("annotated tag git ref has no sha")
case Some(tagSha) => loadTagObject(tagSha).flatMap(body => peelSha(refJson, Some(body)))
case _ => peelSha(refJson, None)
}

def classify(from: String, to: String): BumpKind =
VersionStrategy.npm.classify(from.stripPrefix("v"), to.stripPrefix("v"))
Expand Down
148 changes: 0 additions & 148 deletions modules/core/src/main/scala/zipx/core/MiniJson.scala

This file was deleted.

Loading