diff --git a/.github/workflows/site.yml b/.github/workflows/site.yml new file mode 100644 index 00000000..9ed29585 --- /dev/null +++ b/.github/workflows/site.yml @@ -0,0 +1,28 @@ +# This file was autogenerated using `zio-sbt` via `sbt generateGithubWorkflow` +# task and should be included in the git repository. Please do not edit +# it manually. + +name: Website +'on': + release: + types: + - published +jobs: + publish-docs: + runs-on: ubuntu-latest + steps: + - name: Git Checkout + with: + fetch-depth: '0' + uses: actions/checkout@v3.1.0 + - name: Setup Scala + with: + node-version: 16.x + registry-url: https://registry.npmjs.org + uses: actions/setup-java@v3.6.0 + - env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + name: Publish Docs to NPM Registry + run: sbt docs/publishToNpm + name: Publish Docs to The NPM Registry + diff --git a/project/plugins.sbt b/project/plugins.sbt index 292a9aaf..778041e3 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -17,3 +17,4 @@ addSbtPlugin("de.heikoseeberger" % "sbt-header" % "5.6.0") libraryDependencies += "org.snakeyaml" % "snakeyaml-engine" % "2.5" libraryDependencies += "dev.zio" %% "zio" % "2.0.2" +libraryDependencies += "io.circe" %% "circe-yaml" % "0.14.2" diff --git a/zio-sbt-website/build.sbt b/zio-sbt-website/build.sbt index b85dac9c..59d32962 100644 --- a/zio-sbt-website/build.sbt +++ b/zio-sbt-website/build.sbt @@ -1,3 +1,4 @@ addSbtPlugin("org.scalameta" % "sbt-mdoc" % "2.3.6") -libraryDependencies += "dev.zio" %% "zio" % "2.0.2" +libraryDependencies += "dev.zio" %% "zio" % "2.0.4" +libraryDependencies += "io.circe" %% "circe-yaml" % "0.14.2" diff --git a/zio-sbt-website/src/main/scala/zio/sbt/WebsitePlugin.scala b/zio-sbt-website/src/main/scala/zio/sbt/WebsitePlugin.scala index 4386a450..8ba97691 100644 --- a/zio-sbt-website/src/main/scala/zio/sbt/WebsitePlugin.scala +++ b/zio-sbt-website/src/main/scala/zio/sbt/WebsitePlugin.scala @@ -16,7 +16,7 @@ package zio.sbt -import java.nio.file.{Files, Path, Paths} +import java.nio.file.{Path, Paths} import scala.sys.process.* @@ -207,7 +207,7 @@ object WebsitePlugin extends sbt.AutoPlugin { Unsafe.unsafe { implicit unsafe => Runtime.default.unsafe - .run(WebsitePluginUtils.generateReadme(websiteDir.value.resolve("docs/index.md").toString)) + .run(WebsiteUtils.generateReadme(websiteDir.value.resolve("docs/index.md").toString)) .getOrThrowFiberFailure() } val logger = streams.value.log @@ -221,81 +221,12 @@ object WebsitePlugin extends sbt.AutoPlugin { val template = s"""|# This file was autogenerated using `zio-sbt` via `sbt generateGithubWorkflow` |# task and should be included in the git repository. Please do not edit - |# it manually. + |# it manually. | - |name: website - | - |on: - | release: - | types: [ published ] - | - |jobs: - | publish-docs: - | runs-on: ubuntu-20.04 - | timeout-minutes: 30 - | steps: - | - uses: actions/checkout@v3.1.0 - | with: - | fetch-depth: 0 - | - name: Setup Scala and Java - | uses: actions/setup-java@v3.6.0 - | with: - | distribution: temurin - | java-version: 17 - | check-latest: true - | - uses: actions/setup-node@v3 - | with: - | node-version: '16.x' - | registry-url: 'https://registry.npmjs.org' - | - name: Publishing Docs to NPM Registry - | run: sbt docs/publishToNpm - | env: - | NODE_AUTH_TOKEN: $${{ secrets.NPM_TOKEN }} + |${WebsiteUtils.websiteWorkflow} |""".stripMargin IO.write(new File(".github/workflows/site.yml"), template) } } - -object WebsitePluginUtils { - import zio.* - - import java.nio.charset.StandardCharsets - - def removeYamlHeader(markdown: String): String = - markdown - .split("\n") - .dropWhile(_ == "---") - .dropWhile(_ != "---") - .dropWhile(_ == "---") - .mkString("\n") - - def readFile(pathname: String): Task[String] = - ZIO.attemptBlocking { - val source = scala.io.Source.fromFile(new File(pathname)) - val result = source.getLines().mkString("\n") - source.close() - result - } - - def generateReadme(sourcePath: String): Task[Unit] = - for { - template <- readFile("README.template.md") - mainContent <- readFile(sourcePath).map(md => removeYamlHeader(md)) - comment = - """|[//]: # (This file was autogenerated using `zio-sbt-website` plugin via `sbt generateReadme` command.) - |[//]: # (So please do not edit it manually. Instead, edit `README.template.md` file. This command will replace any) - |[//]: # ({{ main_content }} template tag inside the `README.template.md` file with the main content of the) - |[//]: # ("docs/index.md" file.) - |""".stripMargin - readme <- ZIO.succeed(comment + '\n' + template.replaceFirst("\\{\\{.*main_content.*}}", mainContent)) - _ <- ZIO.attemptBlocking( - Files.write( - Paths.get("README.md"), - readme.getBytes(StandardCharsets.UTF_8) - ) - ) - } yield () - -} diff --git a/zio-sbt-website/src/main/scala/zio/sbt/WebsiteUtils.scala b/zio-sbt-website/src/main/scala/zio/sbt/WebsiteUtils.scala new file mode 100644 index 00000000..d0da75e9 --- /dev/null +++ b/zio-sbt-website/src/main/scala/zio/sbt/WebsiteUtils.scala @@ -0,0 +1,92 @@ +package zio.sbt + +import java.nio.file.{Files, Paths} + +import io.circe.syntax.* +import sbt.File + +import zio.* +import zio.sbt.githubactions.* + +object WebsiteUtils { + + import java.nio.charset.StandardCharsets + + def removeYamlHeader(markdown: String): String = + markdown + .split("\n") + .dropWhile(_ == "---") + .dropWhile(_ != "---") + .dropWhile(_ == "---") + .mkString("\n") + + def readFile(pathname: String): Task[String] = + ZIO.attemptBlocking { + val source = scala.io.Source.fromFile(new File(pathname)) + val result = source.getLines().mkString("\n") + source.close() + result + } + + def generateReadme(sourcePath: String): Task[Unit] = + for { + template <- readFile("README.template.md") + mainContent <- readFile(sourcePath).map(md => removeYamlHeader(md)) + comment = + """|[//]: # (This file was autogenerated using `zio-sbt-website` plugin via `sbt generateReadme` command.) + |[//]: # (So please do not edit it manually. Instead, edit `README.template.md` file. This command will replace any) + |[//]: # ({{ main_content }} template tag inside the `README.template.md` file with the main content of the) + |[//]: # ("docs/index.md" file.) + |""".stripMargin + readme <- ZIO.succeed(comment + '\n' + template.replaceFirst("\\{\\{.*main_content.*}}", mainContent)) + _ <- ZIO.attemptBlocking( + Files.write( + Paths.get("README.md"), + readme.getBytes(StandardCharsets.UTF_8) + ) + ) + } yield () + + val websiteWorkflow: String = + io.circe.yaml + .Printer(dropNullKeys = true) + .pretty( + Workflow( + name = "Website", + triggers = Seq(Trigger.Release(Seq("published"))), + jobs = Seq( + Job( + id = "publish-docs", + name = "Publish Docs to The NPM Registry", + steps = Seq( + Step.StepSequence( + Seq( + Step.SingleStep( + name = "Git Checkout", + uses = Some(ActionRef("actions/checkout@v3.1.0")), + parameters = Map("fetch-depth" -> "0".asJson) + ), + Step.SingleStep( + name = "Setup Scala", + uses = Some(ActionRef("actions/setup-java@v3.6.0")), + parameters = Map( + "node-version" -> "16.x".asJson, + "registry-url" -> "https://registry.npmjs.org".asJson + ) + ), + Step.SingleStep( + name = "Publish Docs to NPM Registry", + run = Some("sbt docs/publishToNpm"), + env = Map( + "NODE_AUTH_TOKEN" -> "${{ secrets.NPM_TOKEN }}" + ) + ) + ) + ) + ) + ) + ) + ).asJson + ) + +} diff --git a/zio-sbt-website/src/main/scala/zio/sbt/githubactions/ScalaWorkflow.scala b/zio-sbt-website/src/main/scala/zio/sbt/githubactions/ScalaWorkflow.scala new file mode 100644 index 00000000..b79477d6 --- /dev/null +++ b/zio-sbt-website/src/main/scala/zio/sbt/githubactions/ScalaWorkflow.scala @@ -0,0 +1,235 @@ +package zio.sbt.githubactions + +import io.circe.* +import io.circe.syntax.* + +import zio.sbt.githubactions.ScalaWorkflow.JavaVersion.AdoptJDK18 + +object ScalaWorkflow { + import Step._ + + def checkoutCurrentBranch(fetchDepth: Int = 0): Step = + SingleStep( + name = "Checkout current branch", + uses = Some(ActionRef("actions/checkout@v2")), + parameters = Map( + "fetch-depth" := fetchDepth + ) + ) + + def setupScala(javaVersion: Option[JavaVersion] = None): Step = + SingleStep( + name = "Setup Java and Scala", + uses = Some(ActionRef("olafurpg/setup-scala@v11")), + parameters = Map( + "java-version" := (javaVersion match { + case None => "${{ matrix.java }}" + case Some(version) => version.asString + }) + ) + ) + + def setupNode(javaVersion: Option[JavaVersion] = None): Step = + SingleStep( + name = "Setup NodeJS", + uses = Some(ActionRef("actions/setup-node@v3")), + parameters = Map( + "node-version" := (javaVersion match { + case None => "16.x" + case Some(version) => version.asString + }), + "registry-url" := "https://registry.npmjs.org" + ) + ) + + def setupGPG(): Step = + SingleStep( + "Setup GPG", + uses = Some(ActionRef("olafurpg/setup-gpg@v3")) + ) + + def cacheSBT( + os: Option[OS] = None, + scalaVersion: Option[ScalaVersion] = None + ): Step = { + val osS = os.map(_.asString).getOrElse("${{ matrix.os }}") + val scalaS = scalaVersion.map(_.version).getOrElse("${{ matrix.scala }}") + + SingleStep( + name = "Cache SBT", + uses = Some(ActionRef("actions/cache@v2")), + parameters = Map( + "path" := Seq( + "~/.ivy2/cache", + "~/.sbt", + "~/.coursier/cache/v1", + "~/.cache/coursier/v1" + ).mkString("\n"), + "key" := s"$osS-sbt-$scalaS-$${{ hashFiles('**/*.sbt') }}-$${{ hashFiles('**/build.properties') }}" + ) + ) + } + + def setupGitUser(): Step = + SingleStep( + name = "Setup GIT user", + uses = Some(ActionRef("fregante/setup-git-user@v1")) + ) + + def runSBT( + name: String, + parameters: List[String], + heapGb: Int = 6, + stackMb: Int = 16, + env: Map[String, String] = Map.empty + ): Step = + SingleStep( + name, + run = Some( + s"sbt -J-XX:+UseG1GC -J-Xmx${heapGb}g -J-Xms${heapGb}g -J-Xss${stackMb}m ${parameters.mkString(" ")}" + ), + env = env + ) + + def storeTargets( + id: String, + directories: List[String], + os: Option[OS] = None, + scalaVersion: Option[ScalaVersion] = None, + javaVersion: Option[JavaVersion] = None + ): Step = { + val osS = os.map(_.asString).getOrElse("${{ matrix.os }}") + val scalaS = scalaVersion.map(_.version).getOrElse("${{ matrix.scala }}") + val javaS = javaVersion.map(_.asString).getOrElse("${{ matrix.java }}") + + StepSequence( + Seq( + SingleStep( + s"Compress $id targets", + run = Some( + s"tar cvf targets.tar ${directories.map(dir => s"$dir/target".dropWhile(_ == '/')).mkString(" ")}" + ) + ), + SingleStep( + s"Upload $id targets", + uses = Some(ActionRef("actions/upload-artifact@v2")), + parameters = Map( + "name" := s"target-$id-$osS-$scalaS-$javaS", + "path" := "targets.tar" + ) + ) + ) + ) + } + + def loadStoredTarget( + id: String, + os: Option[OS] = None, + scalaVersion: Option[ScalaVersion] = None, + javaVersion: Option[JavaVersion] = None + ): Step = { + val osS = os.map(_.asString).getOrElse("${{ matrix.os }}") + val scalaS = scalaVersion.map(_.version).getOrElse("${{ matrix.scala }}") + val javaS = javaVersion.map(_.asString).getOrElse("${{ matrix.java }}") + + StepSequence( + Seq( + SingleStep( + s"Download stored $id targets", + uses = Some(ActionRef("actions/download-artifact@v2")), + parameters = Map( + "name" := s"target-$id-$osS-$scalaS-$javaS" + ) + ), + SingleStep( + s"Inflate $id targets", + run = Some( + "tar xvf targets.tar\nrm targets.tar" + ) + ) + ) + ) + } + + def loadStoredTargets( + ids: List[String], + os: Option[OS] = None, + scalaVersion: Option[ScalaVersion] = None, + javaVersion: Option[JavaVersion] = None + ): Step = + StepSequence( + ids.map(loadStoredTarget(_, os, scalaVersion, javaVersion)) + ) + + def loadPGPSecret(): Step = + SingleStep( + "Load PGP secret", + run = Some(".github/import-key.sh"), + env = Map("PGP_SECRET" -> "${{ secrets.PGP_SECRET }}") + ) + + def turnstyle(): Step = + SingleStep( + "Turnstyle", + uses = Some(ActionRef("softprops/turnstyle@v1")), + env = Map( + "GITHUB_TOKEN" -> "${{ secrets.ADMIN_GITHUB_TOKEN }}" + ) + ) + + def collectDockerLogs(): Step = + SingleStep( + "Collect Docker logs", + uses = Some(ActionRef("jwalton/gh-docker-logs@v1")) + ) + + val isMaster: Condition = Condition.Expression( + "github.ref == 'refs/heads/master'" + ) + val isNotMaster: Condition = Condition.Expression( + "github.ref != 'refs/heads/master'" + ) + def isScalaVersion(version: ScalaVersion): Condition = Condition.Expression( + s"matrix.scala == '${version.version}'" + ) + def isNotScalaVersion(version: ScalaVersion): Condition = + Condition.Expression( + s"matrix.scala != '${version.version}'" + ) + val isFailure: Condition = Condition.Function("failure()") + + case class ScalaVersion(version: String) + + sealed trait JavaVersion { + val asString: String + } + object JavaVersion { + case object AdoptJDK18 extends JavaVersion { + override val asString: String = "adopt@1.8" + } + case object ZuluJDK17 extends JavaVersion { + override val asString: String = "zulu@1.17" + } + } + + implicit class JobOps(job: Job) { + def matrix( + scalaVersions: Seq[ScalaVersion], + operatingSystems: Seq[OS] = Seq(OS.UbuntuLatest), + javaVersions: Seq[JavaVersion] = Seq(AdoptJDK18) + ): Job = + job.copy( + strategy = Some( + Strategy( + matrix = Map( + "os" -> operatingSystems.map(_.asString).toList, + "scala" -> scalaVersions.map(_.version).toList, + "java" -> javaVersions.map(_.asString).toList + ) + ) + ), + runsOn = "${{ matrix.os }}" + ) + } + +} diff --git a/zio-sbt-website/src/main/scala/zio/sbt/githubactions/model.scala b/zio-sbt-website/src/main/scala/zio/sbt/githubactions/model.scala new file mode 100644 index 00000000..55741255 --- /dev/null +++ b/zio-sbt-website/src/main/scala/zio/sbt/githubactions/model.scala @@ -0,0 +1,256 @@ +package zio.sbt.githubactions + +import io.circe.* +import io.circe.syntax.* + +import zio.sbt.githubactions.Step.StepSequence + +sealed trait OS { + val asString: String +} +object OS { + case object UbuntuLatest extends OS { val asString = "ubuntu-latest" } +} + +sealed trait Branch +object Branch { + case object All extends Branch + case class Named(name: String) extends Branch + + implicit val encoder: Encoder[Branch] = { + case All => Json.fromString("*") + case Named(name) => Json.fromString(name) + } +} + +sealed trait Trigger { + def toKeyValuePair: (String, Json) +} +object Trigger { + + case class Release( + releaseTypes: Seq[String] = Seq.empty + ) extends Trigger { + override def toKeyValuePair: (String, Json) = + "release" := Json.obj("types" := releaseTypes) + } + + case class PullRequest( + branches: Seq[Branch] = Seq.empty, + ignoredBranches: Seq[Branch] = Seq.empty + ) extends Trigger { + override def toKeyValuePair: (String, Json) = + "pull_request" := Json.obj( + Seq( + "branches" := branches, + "branches-ignore" := ignoredBranches + ).filter { case (key, data) => data.asArray.exists(_.nonEmpty) }: _* + ) + } + + case class Push( + branches: Seq[Branch] = Seq.empty, + ignoredBranches: Seq[Branch] = Seq.empty + ) extends Trigger { + override def toKeyValuePair: (String, Json) = + "push" := Json.obj( + Seq( + "branches" := branches, + "branches-ignore" := ignoredBranches + ).filter { case (key, data) => data.asArray.exists(_.nonEmpty) }: _* + ) + } +} + +case class Strategy(matrix: Map[String, List[String]]) + +object Strategy { + implicit val encoder: Encoder[Strategy] = + (s: Strategy) => + Json.obj( + "matrix" := s.matrix + ) +} + +case class ActionRef(ref: String) +object ActionRef { + implicit val encoder: Encoder[ActionRef] = + (action: ActionRef) => Json.fromString(action.ref) +} + +sealed trait Condition { + def &&(other: Condition): Condition + def asString: String +} + +object Condition { + case class Expression(expression: String) extends Condition { + def &&(other: Condition): Condition = + other match { + case Expression(otherExpression: String) => + Expression(s"($expression) && ($otherExpression)") + case Function(otherExpression: String) => + throw new IllegalArgumentException("Not supported currently") + } + + def asString: String = s"$${{ $expression }}" + } + + case class Function(expression: String) extends Condition { + def &&(other: Condition): Condition = throw new IllegalArgumentException( + "Not supported currently" + ) + + def asString: String = expression + } + + implicit val encoder: Encoder[Condition] = + (c: Condition) => Json.fromString(c.asString) +} + +sealed trait Step { + def when(condition: Condition): Step + def flatten: Seq[Step.SingleStep] +} +object Step { + case class SingleStep( + name: String, + uses: Option[ActionRef] = None, + condition: Option[Condition] = None, + parameters: Map[String, Json] = Map.empty, + run: Option[String] = None, + env: Map[String, String] = Map.empty + ) extends Step { + override def when(condition: Condition): Step = + copy(condition = Some(condition)) + + override def flatten: Seq[Step.SingleStep] = Seq(this) + } + + case class StepSequence(steps: Seq[Step]) extends Step { + override def when(condition: Condition): Step = + copy(steps = steps.map(_.when(condition))) + + override def flatten: Seq[SingleStep] = + steps.flatMap(_.flatten) + } + + implicit val encoder: Encoder[SingleStep] = + (s: SingleStep) => + Json + .obj( + "name" := s.name, + "uses" := s.uses, + "if" := s.condition, + "with" := (if (s.parameters.nonEmpty) s.parameters.asJson + else Json.Null), + "run" := s.run, + "env" := (if (s.env.nonEmpty) s.env.asJson else Json.Null) + ) +} + +case class ImageRef(ref: String) +object ImageRef { + implicit val encoder: Encoder[ImageRef] = + (image: ImageRef) => Json.fromString(image.ref) +} + +case class ServicePort(inner: Int, outer: Int) +object ServicePort { + implicit val encoder: Encoder[ServicePort] = + (sp: ServicePort) => Json.fromString(s"${sp.inner}:${sp.outer}") +} + +case class Service( + name: String, + image: ImageRef, + env: Map[String, String] = Map.empty, + ports: Seq[ServicePort] = Seq.empty +) +object Service { + implicit val encoder: Encoder[Service] = + (s: Service) => + Json.obj( + "image" := s.image, + "env" := s.env, + "ports" := s.ports + ) +} + +case class Job( + id: String, + name: String, + runsOn: String = "ubuntu-latest", + strategy: Option[Strategy] = None, + steps: Seq[Step] = Seq.empty, + need: Seq[String] = Seq.empty, + services: Seq[Service] = Seq.empty, + condition: Option[Condition] = None +) { + def withStrategy(strategy: Strategy): Job = + copy(strategy = Some(strategy)) + + def withSteps(steps: Step*): Job = + copy(steps = steps) + + def withServices(services: Service*): Job = + copy(services = services) +} + +object Job { + implicit val encoder: Encoder[Job] = + (job: Job) => + Json + .obj( + "name" := job.name, + "runs-on" := job.runsOn, + "strategy" := job.strategy, + "needs" := (if (job.need.nonEmpty) job.need.asJson + else Json.Null), + "services" := (if (job.services.nonEmpty) { + Json.obj( + job.services.map(svc => svc.name := svc): _* + ) + } else { + Json.Null + }), + "if" := job.condition, + "steps" := StepSequence(job.steps).flatten + ) +} + +case class Workflow( + name: String, + triggers: Seq[Trigger] = Seq.empty, + jobs: Seq[Job] = Seq.empty +) { + def on(triggers: Trigger*): Workflow = + copy(triggers = triggers) + + def withJobs(jobs: Job*): Workflow = + copy(jobs = jobs) + + def addJob(job: Job): Workflow = + copy(jobs = jobs :+ job) + + def addJobs(newJobs: Seq[Job]): Workflow = + copy(jobs = jobs ++ newJobs) +} + +object Workflow { + implicit val encoder: Encoder[Workflow] = + (wf: Workflow) => + Json + .obj( + "name" := wf.name, + "on" := (if (wf.triggers.isEmpty) + Json.Null + else { + Json.obj( + wf.triggers + .map(_.toKeyValuePair): _* + ) + }), + "jobs" := Json.obj(wf.jobs.map(job => job.id := job): _*) + ) +}