diff --git a/src/main/scala/sbtghactions/Concurrency.scala b/src/main/scala/sbtghactions/Concurrency.scala new file mode 100644 index 0000000..08941cd --- /dev/null +++ b/src/main/scala/sbtghactions/Concurrency.scala @@ -0,0 +1,22 @@ +/* + * Copyright 2020-2021 Daniel Spiewak + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package sbtghactions + +case class Concurrency( + group: String, + cancelInProgress: Option[Boolean] = None, +) diff --git a/src/main/scala/sbtghactions/GenerativeKeys.scala b/src/main/scala/sbtghactions/GenerativeKeys.scala index de81f7a..5a2b081 100644 --- a/src/main/scala/sbtghactions/GenerativeKeys.scala +++ b/src/main/scala/sbtghactions/GenerativeKeys.scala @@ -36,6 +36,7 @@ trait GenerativeKeys { lazy val githubWorkflowSbtCommand = settingKey[String]("The command which invokes sbt (default: sbt)") lazy val githubWorkflowUseSbtThinClient = settingKey[Boolean]("Whether to use sbt's native thin client, default is false since this can cause issues (see https://github.com/sbt/sbt/issues/6468)") lazy val githubWorkflowIncludeClean = settingKey[Boolean]("Whether to include the clean.yml file (default: true)") + lazy val githubWorkflowConcurrency = settingKey[Option[Concurrency]]("Use concurrency to ensure that only a single workflow within the same concurrency group will run at a time. (default: None)") lazy val githubWorkflowBuildMatrixFailFast = settingKey[Option[Boolean]]("Whether or not to enable the fail-fast strategy (default: None/Enabled)") lazy val githubWorkflowBuildMatrixAdditions = settingKey[Map[String, List[String]]]("A map of additional matrix dimensions for the build job. Each list should be non-empty. (default: {})") diff --git a/src/main/scala/sbtghactions/GenerativePlugin.scala b/src/main/scala/sbtghactions/GenerativePlugin.scala index c3175a5..2c5df95 100644 --- a/src/main/scala/sbtghactions/GenerativePlugin.scala +++ b/src/main/scala/sbtghactions/GenerativePlugin.scala @@ -37,6 +37,14 @@ object GenerativePlugin extends AutoPlugin { type WorkflowStep = sbtghactions.WorkflowStep val WorkflowStep = sbtghactions.WorkflowStep + type WorkflowAction = sbtghactions.WorkflowAction + + type WorkflowSteps = sbtghactions.WorkflowSteps + val WorkflowSteps = sbtghactions.WorkflowSteps + + type WorkflowApply = sbtghactions.WorkflowApply + val WorkflowApply = sbtghactions.WorkflowApply + type RefPredicate = sbtghactions.RefPredicate val RefPredicate = sbtghactions.RefPredicate @@ -70,6 +78,12 @@ object GenerativePlugin extends AutoPlugin { type PermissionValue = sbtghactions.PermissionValue val PermissionValue = sbtghactions.PermissionValue + type Concurrency = sbtghactions.Concurrency + val Concurrency = sbtghactions.Concurrency + + type Secrets = sbtghactions.Secrets + val Secrets = sbtghactions.Secrets + type Graalvm = sbtghactions.Graalvm val Graalvm = sbtghactions.Graalvm } @@ -172,6 +186,24 @@ object GenerativePlugin extends AutoPlugin { s"(startsWith($target, 'refs/heads/') && endsWith($target, '$name'))" } + def compileConcurrency(concurrency: Concurrency): String = + concurrency.cancelInProgress match { + case Some(value) => + val fields = s"""group: ${wrap(concurrency.group)} + |cancel-in-progress: ${wrap(value.toString)}""".stripMargin + s"""concurrency: + |${indent(fields, 1)}""".stripMargin + + case None => + s"concurrency: ${wrap(concurrency.group)}" + } + + def compileSecrets(secrets: Secrets): String = + secrets match { + case Secrets.Inherit => "secrets: inherit" + case Secrets.Explicit(secretMap) => compileEnv(secretMap, "secrets") + } + def compileEnvironment(environment: JobEnvironment): String = environment.url match { case Some(url) => @@ -340,6 +372,8 @@ ${indent(rendered.mkString("\n"), 1)}""" def compileJob(job: WorkflowJob, sbt: String): String = { + val renderedName = s"""name: ${wrap(job.name)}""" + val renderedNeeds = if (job.needs.isEmpty) "" else @@ -350,6 +384,9 @@ ${indent(rendered.mkString("\n"), 1)}""" val renderedCond = job.cond.map(wrap).map("\nif: " + _).getOrElse("") + val renderedConcurrency = + job.concurrency.map(compileConcurrency).map("\n" + _).getOrElse("") + val renderedContainer = job.container match { case Some(JobContainer(image, credentials, env, volumes, ports, options)) => if (credentials.isEmpty && env.isEmpty && volumes.isEmpty && ports.isEmpty && options.isEmpty) { @@ -467,24 +504,59 @@ ${indent(rendered.mkString("\n"), 1)}""" val declareShell = job.oses.exists(_.contains("windows")) - val runsOn = if (job.runsOnExtraLabels.isEmpty) - s"$${{ matrix.os }}" - else - job.runsOnExtraLabels.mkString(s"""[ "$${{ matrix.os }}", """, ", ", " ]" ) + val runsOn = job.action match { + case steps: WorkflowSteps if steps.runsOnExtraLabels.isEmpty => + "\nruns-on: ${{ matrix.os }}" + case steps: WorkflowSteps => + steps.runsOnExtraLabels.mkString(s"""\nruns-on: [ "$${{ matrix.os }}", """, ", ", " ]") + case _ => + "" + } val renderedFailFast = job.matrixFailFast.fold("")("\n fail-fast: " + _) - val body = s"""name: ${wrap(job.name)}${renderedNeeds}${renderedCond} -strategy:${renderedFailFast} + val renderedStrategy = s"""\nstrategy:${renderedFailFast} matrix: os:${compileList(job.oses, 3)} scala:${compileList(job.scalas, 3)} - java:${compileList(job.javas.map(_.render), 3)}${renderedMatrices} -runs-on: ${runsOn}${renderedEnvironment}${renderedContainer}${renderedTimeout}${renderedPerm}${renderedEnv} -steps: -${indent(job.steps.map(compileStep(_, sbt, job.sbtStepPreamble, declareShell = declareShell)).mkString("\n\n"), 1)}""" + java:${compileList(job.javas.map(_.render), 3)}""" + + val renderedSteps = job.action match { + case steps: WorkflowSteps => + "\nsteps:\n" + + indent(steps.steps.map(compileStep(_, sbt, steps.sbtStepPreamble, declareShell = declareShell)).mkString("\n\n"), 1) + case _ => + "" + } - s"${job.id}:\n${indent(body, 1)}" + val renderedUses = job.action match { + case apply: WorkflowApply => + val renderedSecrets = + apply.secrets.map(compileSecrets).map("\n" + _).getOrElse("") + + s"\nuses: ${apply.ref}${renderParams(apply.params)}${renderedSecrets}" + case _ => + "" + } + + val content = List( + renderedName, + renderedNeeds, + renderedCond, + renderedStrategy, + renderedMatrices, + runsOn, + renderedEnvironment, + renderedContainer, + renderedTimeout, + renderedPerm, + renderedEnv, + renderedConcurrency, + renderedSteps, + renderedUses, + ).reduce(_ ++ _) + + s"${job.id}:\n${indent(content, 1)}" } def compileWorkflow( @@ -495,6 +567,7 @@ ${indent(job.steps.map(compileStep(_, sbt, job.sbtStepPreamble, declareShell = d prEventTypes: List[PREventType], permissions: Option[Permissions], env: Map[String, String], + concurrency: Option[Concurrency], jobs: List[WorkflowJob], sbt: String) : String = { @@ -510,6 +583,9 @@ ${indent(job.steps.map(compileStep(_, sbt, job.sbtStepPreamble, declareShell = d else renderedPermissionsPre + "\n\n" + val renderedConcurrency = + concurrency.map(compileConcurrency).map(_ + "\n\n").getOrElse("") + val renderedTypesPre = prEventTypes.map(compilePREventType).mkString("[", ", ", "]") val renderedTypes = if (prEventTypes.sortBy(_.toString) == PREventType.Defaults) "" @@ -546,7 +622,7 @@ on: push: branches: [${branches.map(wrap).mkString(", ")}]$renderedTags$renderedPaths -${renderedPerm}${renderedEnv}jobs: +${renderedPerm}${renderedEnv}${renderedConcurrency}jobs: ${indent(jobs.map(compileJob(_, sbt)).mkString("\n\n"), 1)} """ } @@ -557,7 +633,7 @@ ${indent(jobs.map(compileJob(_, sbt)).mkString("\n\n"), 1)} // This is currently set to false because of https://github.com/sbt/sbt/issues/6468. When a new SBT version is // released that fixes this issue then check for that SBT version (or higher) and set to true. githubWorkflowUseSbtThinClient := false, - + githubWorkflowConcurrency := None, githubWorkflowBuildMatrixFailFast := None, githubWorkflowBuildMatrixAdditions := Map(), githubWorkflowBuildMatrixInclusions := Seq(), @@ -783,6 +859,7 @@ ${indent(jobs.map(compileJob(_, sbt)).mkString("\n\n"), 1)} githubWorkflowPREventTypes.value.toList, githubWorkflowPermissions.value, githubWorkflowEnv.value, + githubWorkflowConcurrency.value, githubWorkflowGeneratedCI.value.toList, sbt) } diff --git a/src/main/scala/sbtghactions/Secrets.scala b/src/main/scala/sbtghactions/Secrets.scala new file mode 100644 index 0000000..c6fc0c7 --- /dev/null +++ b/src/main/scala/sbtghactions/Secrets.scala @@ -0,0 +1,24 @@ +/* + * Copyright 2020-2021 Daniel Spiewak + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package sbtghactions + +sealed trait Secrets extends Product with Serializable + +object Secrets { + case object Inherit extends Secrets + case class Explicit(secrets: Map[String, String]) extends Secrets +} diff --git a/src/main/scala/sbtghactions/WorkflowAction.scala b/src/main/scala/sbtghactions/WorkflowAction.scala new file mode 100644 index 0000000..c88e957 --- /dev/null +++ b/src/main/scala/sbtghactions/WorkflowAction.scala @@ -0,0 +1,32 @@ +/* + * Copyright 2020-2021 Daniel Spiewak + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package sbtghactions + +sealed trait WorkflowAction + +final case class WorkflowSteps( + steps: List[WorkflowStep], + sbtStepPreamble: List[String] = List(), + runsOnExtraLabels: List[String] = List(), +) extends WorkflowAction + + +final case class WorkflowApply( + ref: String, + params: Map[String, String] = Map.empty, + secrets: Option[Secrets] = None, +) extends WorkflowAction diff --git a/src/main/scala/sbtghactions/WorkflowJob.scala b/src/main/scala/sbtghactions/WorkflowJob.scala index 462b8f3..87c080b 100644 --- a/src/main/scala/sbtghactions/WorkflowJob.scala +++ b/src/main/scala/sbtghactions/WorkflowJob.scala @@ -19,6 +19,28 @@ package sbtghactions import scala.concurrent.duration.FiniteDuration final case class WorkflowJob( + id: String, + name: String, + action: WorkflowAction, + cond: Option[String], + permissions: Option[Permissions], + env: Map[String, String], + oses: List[String], + scalas: List[String], + javas: List[JavaSpec], + needs: List[String], + matrixFailFast: Option[Boolean], + matrixAdds: Map[String, List[String]], + matrixIncs: List[MatrixInclude], + matrixExcs: List[MatrixExclude], + container: Option[JobContainer], + environment: Option[JobEnvironment], + concurrency: Option[Concurrency], + timeout: Option[FiniteDuration], +) + +object WorkflowJob { + def apply( id: String, name: String, steps: List[WorkflowStep], @@ -37,4 +59,71 @@ final case class WorkflowJob( runsOnExtraLabels: List[String] = List(), container: Option[JobContainer] = None, environment: Option[JobEnvironment] = None, - timeout: Option[FiniteDuration] = None) + concurrency: Option[Concurrency] = None, + timeout: Option[FiniteDuration] = None + ): WorkflowJob = + WorkflowJob( + id, + name, + action = WorkflowSteps(steps, sbtStepPreamble, runsOnExtraLabels), + cond, + permissions, + env, + oses, + scalas, + javas, + needs, + matrixFailFast, + matrixAdds, + matrixIncs, + matrixExcs, + container, + environment, + concurrency, + timeout + ) + + def use( + id: String, + name: String, + ref: String, + params: Map[String, String] = Map.empty, + secrets: Option[Secrets] = None, + cond: Option[String] = None, + permissions: Option[Permissions] = None, + env: Map[String, String] = Map(), + oses: List[String] = List("ubuntu-latest"), + scalas: List[String] = List("2.13.10"), + javas: List[JavaSpec] = List(JavaSpec.zulu("8")), + needs: List[String] = List(), + matrixFailFast: Option[Boolean] = None, + matrixAdds: Map[String, List[String]] = Map(), + matrixIncs: List[MatrixInclude] = List(), + matrixExcs: List[MatrixExclude] = List(), + container: Option[JobContainer] = None, + environment: Option[JobEnvironment] = None, + concurrency: Option[Concurrency] = None, + timeout: Option[FiniteDuration] = None, + ): WorkflowJob = + WorkflowJob( + id, + name, + action = WorkflowApply(ref, params, secrets), + cond, + permissions, + env, + oses, + scalas, + javas, + needs, + matrixFailFast, + matrixAdds, + matrixIncs, + matrixExcs, + container, + environment, + concurrency, + timeout + ) + +} diff --git a/src/test/scala/sbtghactions/GenerativePluginSpec.scala b/src/test/scala/sbtghactions/GenerativePluginSpec.scala index c0c2ed5..cefdd1b 100644 --- a/src/test/scala/sbtghactions/GenerativePluginSpec.scala +++ b/src/test/scala/sbtghactions/GenerativePluginSpec.scala @@ -47,7 +47,7 @@ class GenerativePluginSpec extends Specification { |${" " * 2} |""".stripMargin - compileWorkflow("test", List("main"), Nil, Paths.None, PREventType.Defaults, None, Map(), Nil, "sbt") mustEqual expected + compileWorkflow("test", List("main"), Nil, Paths.None, PREventType.Defaults, None, Map(), None, Nil, "sbt") mustEqual expected } "produce the appropriate skeleton around a zero-job workflow with non-empty tags" in { @@ -65,7 +65,7 @@ class GenerativePluginSpec extends Specification { |${" " * 2} |""".stripMargin - compileWorkflow("test", List("main"), List("howdy"), Paths.None, PREventType.Defaults, None, Map(), Nil, "sbt") mustEqual expected + compileWorkflow("test", List("main"), List("howdy"), Paths.None, PREventType.Defaults, None, Map(), None, Nil, "sbt") mustEqual expected } "respect non-default pr types" in { @@ -83,7 +83,7 @@ class GenerativePluginSpec extends Specification { |${" " * 2} |""".stripMargin - compileWorkflow("test", List("main"), Nil, Paths.None, List(PREventType.ReadyForReview, PREventType.ReviewRequested, PREventType.Opened), None, Map(), Nil, "sbt") mustEqual expected + compileWorkflow("test", List("main"), Nil, Paths.None, List(PREventType.ReadyForReview, PREventType.ReviewRequested, PREventType.Opened), None, Map(), None, Nil, "sbt") mustEqual expected } "compile a one-job workflow targeting multiple branch patterns with a environment variables" in { @@ -126,6 +126,7 @@ class GenerativePluginSpec extends Specification { ))), Map( "GITHUB_TOKEN" -> s"$${{ secrets.GITHUB_TOKEN }}"), + None, List( WorkflowJob( "build", @@ -176,6 +177,7 @@ class GenerativePluginSpec extends Specification { PREventType.Defaults, None, Map(), + None, List( WorkflowJob( "build", @@ -221,6 +223,7 @@ class GenerativePluginSpec extends Specification { PREventType.Defaults, None, Map(), + None, List( WorkflowJob( "build", @@ -273,6 +276,7 @@ class GenerativePluginSpec extends Specification { PREventType.Defaults, None, Map(), + None, List( WorkflowJob( "build", @@ -305,7 +309,7 @@ class GenerativePluginSpec extends Specification { |${" " * 2} |""".stripMargin - compileWorkflow("test", List("main"), Nil, Paths.Include(List("**.scala", "**.sbt")), PREventType.Defaults, None, Map(), Nil, "sbt") mustEqual expected + compileWorkflow("test", List("main"), Nil, Paths.Include(List("**.scala", "**.sbt")), PREventType.Defaults, None, Map(), None, Nil, "sbt") mustEqual expected } "render ignored paths on pull_request and push" in { @@ -324,7 +328,69 @@ class GenerativePluginSpec extends Specification { |${" " * 2} |""".stripMargin - compileWorkflow("test", List("main"), Nil, Paths.Ignore(List("docs/**")), PREventType.Defaults, None, Map(), Nil, "sbt") mustEqual expected + compileWorkflow("test", List("main"), Nil, Paths.Ignore(List("docs/**")), PREventType.Defaults, None, Map(), None, Nil, "sbt") mustEqual expected + } + + "render shorthand form for concurrency without specific cancel-in-progress property" in { + val expected = header + s""" + |name: test + | + |on: + | pull_request: + | branches: [main] + | push: + | branches: [main] + | + |concurrency: test-group + | + |jobs: + |${" " * 2} + |""".stripMargin + + compileWorkflow( + "test", + List("main"), + Nil, + Paths.None, + PREventType.Defaults, + None, + Map(), + Some(Concurrency("test-group")), + Nil, + "sbt", + ) mustEqual expected + } + + "render extended form for concurrency with specific cancel-in-progress property" in { + val expected = header + s""" + |name: test + | + |on: + | pull_request: + | branches: [main] + | push: + | branches: [main] + | + |concurrency: + | group: test-group + | cancel-in-progress: true + | + |jobs: + |${" " * 2} + |""".stripMargin + + compileWorkflow( + "test", + List("main"), + Nil, + Paths.None, + PREventType.Defaults, + None, + Map(), + Some(Concurrency("test-group", Some(true))), + Nil, + "sbt", + ) mustEqual expected } } @@ -820,6 +886,30 @@ class GenerativePluginSpec extends Specification { runs-on: $${{ matrix.os }} timeout-minutes: 60 + steps: + - run: csbt ci-release""" + } + + "compile a job with concurrency" in { + val results = compileJob( + WorkflowJob( + "publish", + "Publish Release", + List( + WorkflowStep.Sbt(List("ci-release"))), + concurrency = Some(Concurrency("test-group")), + ), + "csbt") + + results mustEqual s"""publish: + name: Publish Release + strategy: + matrix: + os: [ubuntu-latest] + scala: [2.13.10] + java: [zulu@8] + runs-on: $${{ matrix.os }} + concurrency: test-group steps: - run: csbt ci-release""" } @@ -1002,6 +1092,124 @@ class GenerativePluginSpec extends Specification { - name: Checkout current branch (fast) uses: actions/checkout@v5""" } + + "compile a job which delegates to another via 'using'" in { + val results = compileJob( + WorkflowJob.use( + "publish", + "Publish Release", + "./.github/workflows/dependant.yml"), + "csbt") + + results mustEqual s"""publish: + name: Publish Release + strategy: + matrix: + os: [ubuntu-latest] + scala: [2.13.10] + java: [zulu@8] + uses: ./.github/workflows/dependant.yml""" + } + + "compile a job which delegates to another via 'using', with parameters" in { + val results = compileJob( + WorkflowJob.use( + "publish", + "Publish Release", + "sbt/sbt-github-actions/.github/workflows/dependant.yml@main", + Map("abc" -> "def") + ), + "csbt") + + results mustEqual s"""publish: + name: Publish Release + strategy: + matrix: + os: [ubuntu-latest] + scala: [2.13.10] + java: [zulu@8] + uses: sbt/sbt-github-actions/.github/workflows/dependant.yml@main + with: + abc: def""" + } + + "compile a job which delegates to another via 'using', with inherited secrets" in { + val results = compileJob( + WorkflowJob.use( + "publish", + "Publish Release", + "./.github/workflows/dependant.yml", + secrets = Some(Secrets.Inherit) + ), + "csbt") + + results mustEqual s"""publish: + name: Publish Release + strategy: + matrix: + os: [ubuntu-latest] + scala: [2.13.10] + java: [zulu@8] + uses: ./.github/workflows/dependant.yml + secrets: inherit""" + } + + "compile a job which delegates to another via 'using', with explicit secrets" in { + val results = compileJob( + WorkflowJob.use( + "publish", + "Publish Release", + "./.github/workflows/dependant.yml", + secrets = Some(Secrets.Explicit(Map("abc" -> "def"))) + ), + "csbt") + + results mustEqual s"""publish: + name: Publish Release + strategy: + matrix: + os: [ubuntu-latest] + scala: [2.13.10] + java: [zulu@8] + uses: ./.github/workflows/dependant.yml + secrets: + abc: def""" + } + + "compile a job which delegates to another via 'using', with supported keywords" in { + val results = compileJob( + WorkflowJob.use( + "publish", + "Publish Release", + "./.github/workflows/dependant.yml", + params = Map("abc" -> "def"), + secrets = Some(Secrets.Explicit(Map("abc" -> "def"))), + cond = Some("true"), + permissions = Some(Permissions.ReadAll), + needs = List("build"), + concurrency = Some(Concurrency("publish-group", Some(true))), + ), + "csbt") + + results mustEqual s"""publish: + name: Publish Release + needs: [build] + if: true + strategy: + matrix: + os: [ubuntu-latest] + scala: [2.13.10] + java: [zulu@8] + permissions: read-all + concurrency: + group: publish-group + cancel-in-progress: true + uses: ./.github/workflows/dependant.yml + with: + abc: def + secrets: + abc: def""" + } } "predicate compilation" >> {