diff --git a/github-workflows-kt/api/github-workflows-kt.api b/github-workflows-kt/api/github-workflows-kt.api index de9be120b..f7f25e4cc 100644 --- a/github-workflows-kt/api/github-workflows-kt.api +++ b/github-workflows-kt/api/github-workflows-kt.api @@ -3237,19 +3237,40 @@ public final class io/github/typesafegithub/workflows/yaml/CaseKt { public static final fun snakeCaseOf (Ljava/lang/String;)Ljava/lang/String; } +public abstract interface class io/github/typesafegithub/workflows/yaml/CheckoutActionVersionSource { +} + +public final class io/github/typesafegithub/workflows/yaml/CheckoutActionVersionSource$BundledWithLibrary : io/github/typesafegithub/workflows/yaml/CheckoutActionVersionSource { + public static final field INSTANCE Lio/github/typesafegithub/workflows/yaml/CheckoutActionVersionSource$BundledWithLibrary; +} + +public final class io/github/typesafegithub/workflows/yaml/CheckoutActionVersionSource$Given : io/github/typesafegithub/workflows/yaml/CheckoutActionVersionSource { + public fun (Ljava/lang/String;)V + public final fun getVersion ()Ljava/lang/String; +} + +public final class io/github/typesafegithub/workflows/yaml/CheckoutActionVersionSource$InferFromClasspath : io/github/typesafegithub/workflows/yaml/CheckoutActionVersionSource { + public fun ()V + public fun (Ljava/lang/String;)V + public synthetic fun (Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getCheckoutActionClassFQN ()Ljava/lang/String; +} + public abstract interface class io/github/typesafegithub/workflows/yaml/ConsistencyCheckJobConfig { } public final class io/github/typesafegithub/workflows/yaml/ConsistencyCheckJobConfig$Configuration : io/github/typesafegithub/workflows/yaml/ConsistencyCheckJobConfig { - public fun (Ljava/lang/String;Ljava/util/Map;Lkotlin/jvm/functions/Function1;Z)V + public fun (Ljava/lang/String;Ljava/util/Map;Lio/github/typesafegithub/workflows/yaml/CheckoutActionVersionSource;Lkotlin/jvm/functions/Function1;Z)V public final fun component1 ()Ljava/lang/String; public final fun component2 ()Ljava/util/Map; - public final fun component3 ()Lkotlin/jvm/functions/Function1; - public final fun component4 ()Z - public final fun copy (Ljava/lang/String;Ljava/util/Map;Lkotlin/jvm/functions/Function1;Z)Lio/github/typesafegithub/workflows/yaml/ConsistencyCheckJobConfig$Configuration; - public static synthetic fun copy$default (Lio/github/typesafegithub/workflows/yaml/ConsistencyCheckJobConfig$Configuration;Ljava/lang/String;Ljava/util/Map;Lkotlin/jvm/functions/Function1;ZILjava/lang/Object;)Lio/github/typesafegithub/workflows/yaml/ConsistencyCheckJobConfig$Configuration; + public final fun component3 ()Lio/github/typesafegithub/workflows/yaml/CheckoutActionVersionSource; + public final fun component4 ()Lkotlin/jvm/functions/Function1; + public final fun component5 ()Z + public final fun copy (Ljava/lang/String;Ljava/util/Map;Lio/github/typesafegithub/workflows/yaml/CheckoutActionVersionSource;Lkotlin/jvm/functions/Function1;Z)Lio/github/typesafegithub/workflows/yaml/ConsistencyCheckJobConfig$Configuration; + public static synthetic fun copy$default (Lio/github/typesafegithub/workflows/yaml/ConsistencyCheckJobConfig$Configuration;Ljava/lang/String;Ljava/util/Map;Lio/github/typesafegithub/workflows/yaml/CheckoutActionVersionSource;Lkotlin/jvm/functions/Function1;ZILjava/lang/Object;)Lio/github/typesafegithub/workflows/yaml/ConsistencyCheckJobConfig$Configuration; public fun equals (Ljava/lang/Object;)Z public final fun getAdditionalSteps ()Lkotlin/jvm/functions/Function1; + public final fun getCheckoutActionVersion ()Lio/github/typesafegithub/workflows/yaml/CheckoutActionVersionSource; public final fun getCondition ()Ljava/lang/String; public final fun getEnv ()Ljava/util/Map; public final fun getUseLocalBindingsServerAsFallback ()Z diff --git a/github-workflows-kt/build.gradle.kts b/github-workflows-kt/build.gradle.kts index 316be19e4..0d87ce296 100644 --- a/github-workflows-kt/build.gradle.kts +++ b/github-workflows-kt/build.gradle.kts @@ -26,6 +26,7 @@ dependencies { implementation("it.krzeminski:snakeyaml-engine-kmp:4.0.1") implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:1.9.0") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0") + implementation(kotlin("reflect")) implementation(projects.sharedInternal) ksp(projects.codeGenerator) diff --git a/github-workflows-kt/src/main/kotlin/io/github/typesafegithub/workflows/yaml/ConsistencyCheckJob.kt b/github-workflows-kt/src/main/kotlin/io/github/typesafegithub/workflows/yaml/ConsistencyCheckJob.kt index c59b41e81..f82a222c5 100644 --- a/github-workflows-kt/src/main/kotlin/io/github/typesafegithub/workflows/yaml/ConsistencyCheckJob.kt +++ b/github-workflows-kt/src/main/kotlin/io/github/typesafegithub/workflows/yaml/ConsistencyCheckJob.kt @@ -5,11 +5,15 @@ import io.github.typesafegithub.workflows.domain.Job import io.github.typesafegithub.workflows.domain.JobOutputs import io.github.typesafegithub.workflows.domain.RunnerType.UbuntuLatest import io.github.typesafegithub.workflows.domain.actions.CustomAction +import io.github.typesafegithub.workflows.domain.actions.RegularAction import io.github.typesafegithub.workflows.dsl.WorkflowBuilder import io.github.typesafegithub.workflows.dsl.expressions.expr import io.github.typesafegithub.workflows.internal.relativeToAbsolute import java.nio.file.Path import kotlin.io.path.invariantSeparatorsPathString +import kotlin.reflect.KParameter +import kotlin.reflect.full.primaryConstructor +import kotlin.reflect.jvm.isAccessible @Suppress("LongMethod") internal fun WorkflowBuilder.consistencyCheckJob( @@ -37,6 +41,16 @@ internal fun WorkflowBuilder.consistencyCheckJob( condition = consistencyCheckJobConfig.condition, env = consistencyCheckJobConfig.env, ) { + val checkoutActionVersion = + when (consistencyCheckJobConfig.checkoutActionVersion) { + CheckoutActionVersionSource.BundledWithLibrary -> "v4" + is CheckoutActionVersionSource.Given -> consistencyCheckJobConfig.checkoutActionVersion.version + is CheckoutActionVersionSource.InferFromClasspath -> + inferCheckoutActionVersionFromClasspath( + consistencyCheckJobConfig.checkoutActionVersion.checkoutActionClassFQN, + ) + } + uses( name = "Check out", // Since this action is used in a simple way, and we actually don't want to update the version @@ -46,7 +60,7 @@ internal fun WorkflowBuilder.consistencyCheckJob( CustomAction( actionOwner = "actions", actionName = "checkout", - actionVersion = "v4", + actionVersion = checkoutActionVersion, ), ) @@ -99,3 +113,26 @@ internal fun WorkflowBuilder.consistencyCheckJob( ) } } + +private fun inferCheckoutActionVersionFromClasspath(checkoutActionClassFQN: String): String { + val clazz: Class<*> = + try { + Class.forName(checkoutActionClassFQN) + } catch (_: ClassNotFoundException) { + error( + "actions/checkout is not found in the classpath! " + + "Either add a dependency on it (`@file:DependsOn(\"actions:checkout:\")`), " + + "or don't use CheckoutActionVersionSource.InferFromClasspath()", + ) + } as Class<*> + // It's easier to call the primary constructor, even though it's private, because + // the public constructor requires named arguments, and I'm not sure how to call + // it with these. + val constructor = + clazz.kotlin.primaryConstructor!!.also { + it.isAccessible = true + } + val args: Map = constructor.parameters.associateWith { null } + val bindingObject = constructor.callBy(args) as RegularAction<*> + return bindingObject.actionVersion +} diff --git a/github-workflows-kt/src/main/kotlin/io/github/typesafegithub/workflows/yaml/ConsistencyCheckJobConfig.kt b/github-workflows-kt/src/main/kotlin/io/github/typesafegithub/workflows/yaml/ConsistencyCheckJobConfig.kt index e2eb6c03e..ec6437fd0 100644 --- a/github-workflows-kt/src/main/kotlin/io/github/typesafegithub/workflows/yaml/ConsistencyCheckJobConfig.kt +++ b/github-workflows-kt/src/main/kotlin/io/github/typesafegithub/workflows/yaml/ConsistencyCheckJobConfig.kt @@ -7,6 +7,7 @@ public val DEFAULT_CONSISTENCY_CHECK_JOB_CONFIG: ConsistencyCheckJobConfig.Confi ConsistencyCheckJobConfig.Configuration( condition = null, env = emptyMap(), + checkoutActionVersion = CheckoutActionVersionSource.BundledWithLibrary, additionalSteps = null, useLocalBindingsServerAsFallback = false, ) @@ -17,6 +18,11 @@ public sealed interface ConsistencyCheckJobConfig { public data class Configuration( val condition: String?, val env: Map, + /** + * Configures what version of https://github.com/actions/checkout is used in the consistency check job. + * Lets the user choose between convenience of automatic updates and more determinism and control if required. + */ + val checkoutActionVersion: CheckoutActionVersionSource, val additionalSteps: (JobBuilder.() -> Unit)?, /** * If the script execution step in the consistency check job fails, another attempt to execute is made with a @@ -27,3 +33,36 @@ public sealed interface ConsistencyCheckJobConfig { val useLocalBindingsServerAsFallback: Boolean, ) : ConsistencyCheckJobConfig } + +public sealed interface CheckoutActionVersionSource { + /** + * Every version of github-workflows-kt library comes with a hardcoded version of this action. + * Since bumping this version is possible only upon major releases (major version bumps), this + * version often lags behind the newest one. + * + * This is the current default option. + */ + public object BundledWithLibrary : CheckoutActionVersionSource + + /** + * In the great majority of workflows, the checkout action is used in a preferred version. + * We can utilize this fact and use the same version in the consistency check job. + * Warning: this option won't work if a dependency on "action:checkout:vN" is not present. + * + * This is the most convenient option, and a candidate to become the default one. + */ + public class InferFromClasspath( + /** + * Specifies the fully qualified name of a binding class for actions/checkout. Can be overridden if a custom + * binding class is provided for this action, under a different FQN. + */ + public val checkoutActionClassFQN: String = "io.github.typesafegithub.workflows.actions.actions.Checkout", + ) : CheckoutActionVersionSource + + /** + * Useful if it's desired to specify a concrete version by hand, right in your workflow. + */ + public class Given( + public val version: String, + ) : CheckoutActionVersionSource +} diff --git a/github-workflows-kt/src/test/kotlin/io/github/typesafegithub/workflows/IntegrationTest.kt b/github-workflows-kt/src/test/kotlin/io/github/typesafegithub/workflows/IntegrationTest.kt index 51c8e8f5e..43568fbe4 100644 --- a/github-workflows-kt/src/test/kotlin/io/github/typesafegithub/workflows/IntegrationTest.kt +++ b/github-workflows-kt/src/test/kotlin/io/github/typesafegithub/workflows/IntegrationTest.kt @@ -6,12 +6,12 @@ import io.github.typesafegithub.workflows.actions.endbug.AddAndCommit import io.github.typesafegithub.workflows.annotations.ExperimentalKotlinLogicStep import io.github.typesafegithub.workflows.domain.Concurrency import io.github.typesafegithub.workflows.domain.RunnerType -import io.github.typesafegithub.workflows.domain.actions.Action import io.github.typesafegithub.workflows.domain.actions.Action.Outputs import io.github.typesafegithub.workflows.domain.actions.RegularAction import io.github.typesafegithub.workflows.domain.triggers.Push import io.github.typesafegithub.workflows.dsl.expressions.expr import io.github.typesafegithub.workflows.dsl.workflow +import io.github.typesafegithub.workflows.yaml.CheckoutActionVersionSource import io.github.typesafegithub.workflows.yaml.ConsistencyCheckJobConfig.Disabled import io.github.typesafegithub.workflows.yaml.DEFAULT_CONSISTENCY_CHECK_JOB_CONFIG import io.github.typesafegithub.workflows.yaml.Preamble.Just @@ -318,6 +318,154 @@ class IntegrationTest : """.trimIndent() } + test("actions/checkout's version given explicitly") { + // when + workflow( + name = "Test workflow", + on = listOf(Push()), + sourceFile = sourceTempFile, + consistencyCheckJobConfig = + DEFAULT_CONSISTENCY_CHECK_JOB_CONFIG.copy( + checkoutActionVersion = CheckoutActionVersionSource.Given("v123"), + ), + ) { + job( + id = "test_job", + runsOn = RunnerType.UbuntuLatest, + ) { + run( + name = "Hello world!", + command = "echo 'hello!'", + ) + } + } + + // then + targetTempFile.readText() shouldBe + """ + # This file was generated using Kotlin DSL (.github/workflows/some_workflow.main.kts). + # If you want to modify the workflow, please change the Kotlin file and regenerate this YAML file. + # Generated with https://github.com/typesafegithub/github-workflows-kt + + name: 'Test workflow' + on: + push: {} + jobs: + check_yaml_consistency: + name: 'Check YAML consistency' + runs-on: 'ubuntu-latest' + steps: + - id: 'step-0' + name: 'Check out' + uses: 'actions/checkout@v123' + - id: 'step-1' + name: 'Execute script' + run: 'rm ''.github/workflows/some_workflow.yaml'' && ''.github/workflows/some_workflow.main.kts''' + - id: 'step-2' + name: 'Consistency check' + run: 'git diff --exit-code ''.github/workflows/some_workflow.yaml''' + test_job: + runs-on: 'ubuntu-latest' + needs: + - 'check_yaml_consistency' + steps: + - id: 'step-0' + name: 'Hello world!' + run: 'echo ''hello!''' + + """.trimIndent() + } + + test("actions/checkout's version inferred from classpath") { + // when + workflow( + name = "Test workflow", + on = listOf(Push()), + sourceFile = sourceTempFile, + consistencyCheckJobConfig = + DEFAULT_CONSISTENCY_CHECK_JOB_CONFIG.copy( + checkoutActionVersion = CheckoutActionVersionSource.InferFromClasspath(), + ), + ) { + job( + id = "test_job", + runsOn = RunnerType.UbuntuLatest, + ) { + run( + name = "Hello world!", + command = "echo 'hello!'", + ) + } + } + + // then + targetTempFile.readText() shouldBe + """ + # This file was generated using Kotlin DSL (.github/workflows/some_workflow.main.kts). + # If you want to modify the workflow, please change the Kotlin file and regenerate this YAML file. + # Generated with https://github.com/typesafegithub/github-workflows-kt + + name: 'Test workflow' + on: + push: {} + jobs: + check_yaml_consistency: + name: 'Check YAML consistency' + runs-on: 'ubuntu-latest' + steps: + - id: 'step-0' + name: 'Check out' + uses: 'actions/checkout@v4' + - id: 'step-1' + name: 'Execute script' + run: 'rm ''.github/workflows/some_workflow.yaml'' && ''.github/workflows/some_workflow.main.kts''' + - id: 'step-2' + name: 'Consistency check' + run: 'git diff --exit-code ''.github/workflows/some_workflow.yaml''' + test_job: + runs-on: 'ubuntu-latest' + needs: + - 'check_yaml_consistency' + steps: + - id: 'step-0' + name: 'Hello world!' + run: 'echo ''hello!''' + + """.trimIndent() + } + + test("actions/checkout's version inferred from classpath but binding class is not available") { + // when + shouldThrow { + workflow( + name = "Test workflow", + on = listOf(Push()), + sourceFile = sourceTempFile, + consistencyCheckJobConfig = + DEFAULT_CONSISTENCY_CHECK_JOB_CONFIG.copy( + checkoutActionVersion = + CheckoutActionVersionSource.InferFromClasspath( + checkoutActionClassFQN = "does.not.Exist", + ), + ), + ) { + job( + id = "test_job", + runsOn = RunnerType.UbuntuLatest, + ) { + run( + name = "Hello world!", + command = "echo 'hello!'", + ) + } + } + }.also { + it.message shouldBe "actions/checkout is not found in the classpath! " + + "Either add a dependency on it (`@file:DependsOn(\"actions:checkout:\")`), " + + "or don't use CheckoutActionVersionSource.InferFromClasspath()" + } + } + test("with concurrency, default behavior") { // when workflow(