Repository: typesafegithub/github-workflows-kt (kotlin)

Issue:
[Core feature request] runsOn should accept a list
**What feature do you need?**

from https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idruns-on:

> If you specify an array of strings, your workflow will execute on any runner that matches all of the specified runs-on values.

**Do you have an example usage?**

```yaml
jobs:
  build:
    runs-on: [self-hosted, X64]
```

**Is there a workaround for not having this feature? If yes, please describe it.**

no

Pull request description:
Closes #750

Patch:
diff --git a/library/api/library.api b/library/api/library.api
index fbc8fd63c2..d3186427f3 100644
--- a/library/api/library.api
+++ b/library/api/library.api
@@ -247,6 +247,11 @@ public final class io/github/typesafegithub/workflows/domain/PortMapping$Protoco
 }
 
 public abstract interface class io/github/typesafegithub/workflows/domain/RunnerType {
+	public static final field Companion Lio/github/typesafegithub/workflows/domain/RunnerType$Companion;
+}
+
+public final class io/github/typesafegithub/workflows/domain/RunnerType$Companion {
+	public final fun selfHosted ([Ljava/lang/String;)Lio/github/typesafegithub/workflows/domain/RunnerType$Labelled;
 }
 
 public final class io/github/typesafegithub/workflows/domain/RunnerType$Custom : io/github/typesafegithub/workflows/domain/RunnerType {
@@ -260,6 +265,33 @@ public final class io/github/typesafegithub/workflows/domain/RunnerType$Custom :
 	public fun toString ()Ljava/lang/String;
 }
 
+public final class io/github/typesafegithub/workflows/domain/RunnerType$Group : io/github/typesafegithub/workflows/domain/RunnerType {
+	public fun <init> (Ljava/lang/String;Ljava/util/LinkedHashSet;)V
+	public synthetic fun <init> (Ljava/lang/String;Ljava/util/LinkedHashSet;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
+	public fun <init> (Ljava/lang/String;[Ljava/lang/String;)V
+	public final fun component1 ()Ljava/lang/String;
+	public final fun component2 ()Ljava/util/LinkedHashSet;
+	public final fun copy (Ljava/lang/String;Ljava/util/LinkedHashSet;)Lio/github/typesafegithub/workflows/domain/RunnerType$Group;
+	public static synthetic fun copy$default (Lio/github/typesafegithub/workflows/domain/RunnerType$Group;Ljava/lang/String;Ljava/util/LinkedHashSet;ILjava/lang/Object;)Lio/github/typesafegithub/workflows/domain/RunnerType$Group;
+	public fun equals (Ljava/lang/Object;)Z
+	public final fun getLabels ()Ljava/util/LinkedHashSet;
+	public final fun getName ()Ljava/lang/String;
+	public fun hashCode ()I
+	public fun toString ()Ljava/lang/String;
+}
+
+public final class io/github/typesafegithub/workflows/domain/RunnerType$Labelled : io/github/typesafegithub/workflows/domain/RunnerType {
+	public fun <init> (Ljava/util/LinkedHashSet;)V
+	public fun <init> ([Ljava/lang/String;)V
+	public final fun component1 ()Ljava/util/LinkedHashSet;
+	public final fun copy (Ljava/util/LinkedHashSet;)Lio/github/typesafegithub/workflows/domain/RunnerType$Labelled;
+	public static synthetic fun copy$default (Lio/github/typesafegithub/workflows/domain/RunnerType$Labelled;Ljava/util/LinkedHashSet;ILjava/lang/Object;)Lio/github/typesafegithub/workflows/domain/RunnerType$Labelled;
+	public fun equals (Ljava/lang/Object;)Z
+	public final fun getLabels ()Ljava/util/LinkedHashSet;
+	public fun hashCode ()I
+	public fun toString ()Ljava/lang/String;
+}
+
 public final class io/github/typesafegithub/workflows/domain/RunnerType$MacOS1015 : io/github/typesafegithub/workflows/domain/RunnerType {
 	public static final field INSTANCE Lio/github/typesafegithub/workflows/domain/RunnerType$MacOS1015;
 }
@@ -2993,7 +3025,7 @@ public final class io/github/typesafegithub/workflows/yaml/CaseKt {
 }
 
 public final class io/github/typesafegithub/workflows/yaml/JobsToYamlKt {
-	public static final fun toYaml (Lio/github/typesafegithub/workflows/domain/RunnerType;)Ljava/lang/String;
+	public static final fun toYaml (Lio/github/typesafegithub/workflows/domain/RunnerType;)Ljava/lang/Object;
 }
 
 public abstract class io/github/typesafegithub/workflows/yaml/Preamble {
diff --git a/library/src/main/kotlin/io/github/typesafegithub/workflows/domain/RunnerType.kt b/library/src/main/kotlin/io/github/typesafegithub/workflows/domain/RunnerType.kt
index ad31a1a47f..b6b8ef6b6e 100644
--- a/library/src/main/kotlin/io/github/typesafegithub/workflows/domain/RunnerType.kt
+++ b/library/src/main/kotlin/io/github/typesafegithub/workflows/domain/RunnerType.kt
@@ -8,6 +8,22 @@ public sealed interface RunnerType {
     // Custom runner. Could be an expression `runsOn = expr("github.event.inputs.run-on")`
     public data class Custom(val runsOn: String) : RunnerType
 
+    public data class Labelled(val labels: LinkedHashSet<String>) : RunnerType {
+        public constructor(vararg labels: String) : this(LinkedHashSet(labels.toList()))
+
+        init {
+            require(labels.isNotEmpty()) { "At least one label must be provided" }
+        }
+    }
+
+    /**
+     * @see <a href="https://docs.github.com/en/actions/using-jobs/choosing-the-runner-for-a-job#choosing-runners-in-a-group">Choosing runners in a group</a>
+     */
+    @Suppress("MaxLineLength")
+    public data class Group(val name: String, val labels: LinkedHashSet<String>? = null) : RunnerType {
+        public constructor(name: String, vararg labels: String) : this(name, LinkedHashSet(labels.toList()))
+    }
+
     // "Latest" labels
     public object UbuntuLatest : RunnerType
     public object WindowsLatest : RunnerType
@@ -25,4 +41,12 @@ public sealed interface RunnerType {
     // macOS runners
     public object MacOS11 : RunnerType
     public object MacOS1015 : RunnerType
+
+    public companion object {
+        /**
+         * @see <a href="https://docs.github.com/en/actions/using-jobs/choosing-the-runner-for-a-job#choosing-self-hosted-runners">Choosing self-hosted runners</a>
+         */
+        @Suppress("MaxLineLength")
+        public fun selfHosted(vararg labels: String): Labelled = Labelled("self-hosted", *labels)
+    }
 }
diff --git a/library/src/main/kotlin/io/github/typesafegithub/workflows/yaml/JobsToYaml.kt b/library/src/main/kotlin/io/github/typesafegithub/workflows/yaml/JobsToYaml.kt
index 3455be3d80..fb41c70378 100644
--- a/library/src/main/kotlin/io/github/typesafegithub/workflows/yaml/JobsToYaml.kt
+++ b/library/src/main/kotlin/io/github/typesafegithub/workflows/yaml/JobsToYaml.kt
@@ -3,6 +3,7 @@ package io.github.typesafegithub.workflows.yaml
 import io.github.typesafegithub.workflows.domain.Job
 import io.github.typesafegithub.workflows.domain.RunnerType
 import io.github.typesafegithub.workflows.domain.RunnerType.Custom
+import io.github.typesafegithub.workflows.domain.RunnerType.Labelled
 import io.github.typesafegithub.workflows.domain.RunnerType.MacOS1015
 import io.github.typesafegithub.workflows.domain.RunnerType.MacOS11
 import io.github.typesafegithub.workflows.domain.RunnerType.MacOSLatest
@@ -49,9 +50,14 @@ private fun Job<*>.toYaml(): Map<String, Any?> =
         mapOf("steps" to steps.stepsToYaml())
 
 @InternalGithubActionsApi
-public fun RunnerType.toYaml(): String =
+public fun RunnerType.toYaml(): Any =
     when (this) {
         is Custom -> runsOn
+        is Labelled -> labels.toList()
+        is RunnerType.Group -> mapOfNotNullValues(
+            "group" to name,
+            "labels" to labels?.toList(),
+        )
         UbuntuLatest -> "ubuntu-latest"
         WindowsLatest -> "windows-latest"
         MacOSLatest -> "macos-latest"


Test patch:
diff --git a/library/src/test/kotlin/io/github/typesafegithub/workflows/domain/RunnerTypeTest.kt b/library/src/test/kotlin/io/github/typesafegithub/workflows/domain/RunnerTypeTest.kt
new file mode 100644
index 0000000000..a4f1fba79f
--- /dev/null
+++ b/library/src/test/kotlin/io/github/typesafegithub/workflows/domain/RunnerTypeTest.kt
@@ -0,0 +1,17 @@
+package io.github.typesafegithub.workflows.domain
+
+import io.kotest.assertions.throwables.shouldThrow
+import io.kotest.core.spec.style.FunSpec
+
+class RunnerTypeTest : FunSpec({
+    context("Labelled") {
+        test("should throw on invalid arguments") {
+            shouldThrow<IllegalArgumentException> {
+                RunnerType.Labelled()
+            }
+            shouldThrow<IllegalArgumentException> {
+                RunnerType.Labelled(linkedSetOf())
+            }
+        }
+    }
+})
diff --git a/library/src/test/kotlin/io/github/typesafegithub/workflows/yaml/JobsToYamlTest.kt b/library/src/test/kotlin/io/github/typesafegithub/workflows/yaml/JobsToYamlTest.kt
index 302baa9ce2..43ece407e1 100644
--- a/library/src/test/kotlin/io/github/typesafegithub/workflows/yaml/JobsToYamlTest.kt
+++ b/library/src/test/kotlin/io/github/typesafegithub/workflows/yaml/JobsToYamlTest.kt
@@ -347,4 +347,26 @@ class JobsToYamlTest : DescribeSpec({
             ),
         )
     }
+
+    context("RunnerType") {
+        it("renders properly") {
+            RunnerType.selfHosted()
+                .toYaml() shouldBe listOf("self-hosted")
+            RunnerType.selfHosted("self-hosted")
+                .toYaml() shouldBe listOf("self-hosted")
+            RunnerType.selfHosted("test-label", "test-arch", "test-label")
+                .toYaml() shouldBe listOf("self-hosted", "test-label", "test-arch")
+
+            RunnerType.Group("test-group")
+                .toYaml() shouldBe mapOf("group" to "test-group")
+            RunnerType.Group("test-group", "test-label", "test-label-2", "test-label")
+                .toYaml() shouldBe mapOf(
+                "group" to "test-group",
+                "labels" to listOf("test-label", "test-label-2"),
+            )
+
+            RunnerType.Labelled("test-label", "test-label-2", "test-label")
+                .toYaml() shouldBe listOf("test-label", "test-label-2")
+        }
+    }
 })