Skip to content

Commit bd933fe

Browse files
authored
feat(library): add API for adding intended action version in comment (#2118)
Part of #1691. This change adds API to be able to add a comment with an intended action version to the YAML. See an example: ```yaml - id: 'step-0' name: 'Check out' uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # v5.0.0 ``` This follows the approach that Renovate uses, see https://docs.renovatebot.com/modules/manager/github-actions/#digest-pinning-and-updating. It's also a part of implementing [2025-04-27 Enhancements to referring to actions by commit hash](https://github.com/typesafegithub/design-docs/blob/main/2025-04-27%20Enhancements%20to%20referring%20to%20actions%20by%20commit%20hash.md). This change needs to go as the very first one. It needs to land in a library release, so that the server can generate action bindings using the extended `RegularAction` base class. ABI-wise, `RegularAction` is modified in a backward-compatible way because classes inheriting from it are generated by the bindings server. For `CustomAction`, all that is provided is source-level backward compatibility which is IMO good enough.
1 parent a76e611 commit bd933fe

File tree

9 files changed

+170
-6
lines changed

9 files changed

+170
-6
lines changed

github-workflows-kt/api/github-workflows-kt.api

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -529,20 +529,22 @@ public class io/github/typesafegithub/workflows/domain/actions/Action$Outputs {
529529
}
530530

531531
public final class io/github/typesafegithub/workflows/domain/actions/CustomAction : io/github/typesafegithub/workflows/domain/actions/RegularAction {
532-
public fun <init> (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;)V
533-
public synthetic fun <init> (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
532+
public fun <init> (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;)V
533+
public synthetic fun <init> (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
534534
public fun buildOutputObject (Ljava/lang/String;)Lio/github/typesafegithub/workflows/domain/actions/Action$Outputs;
535535
public final fun component1 ()Ljava/lang/String;
536536
public final fun component2 ()Ljava/lang/String;
537537
public final fun component3 ()Ljava/lang/String;
538-
public final fun component4 ()Ljava/util/Map;
539-
public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;)Lio/github/typesafegithub/workflows/domain/actions/CustomAction;
540-
public static synthetic fun copy$default (Lio/github/typesafegithub/workflows/domain/actions/CustomAction;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;ILjava/lang/Object;)Lio/github/typesafegithub/workflows/domain/actions/CustomAction;
538+
public final fun component4 ()Ljava/lang/String;
539+
public final fun component5 ()Ljava/util/Map;
540+
public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;)Lio/github/typesafegithub/workflows/domain/actions/CustomAction;
541+
public static synthetic fun copy$default (Lio/github/typesafegithub/workflows/domain/actions/CustomAction;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;ILjava/lang/Object;)Lio/github/typesafegithub/workflows/domain/actions/CustomAction;
541542
public fun equals (Ljava/lang/Object;)Z
542543
public fun getActionName ()Ljava/lang/String;
543544
public fun getActionOwner ()Ljava/lang/String;
544545
public fun getActionVersion ()Ljava/lang/String;
545546
public final fun getInputs ()Ljava/util/Map;
547+
public fun getIntendedVersion ()Ljava/lang/String;
546548
public fun hashCode ()I
547549
public fun toString ()Ljava/lang/String;
548550
public fun toYamlArguments ()Ljava/util/LinkedHashMap;
@@ -601,9 +603,12 @@ public abstract class io/github/typesafegithub/workflows/domain/actions/LocalAct
601603

602604
public abstract class io/github/typesafegithub/workflows/domain/actions/RegularAction : io/github/typesafegithub/workflows/domain/actions/Action {
603605
public fun <init> (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V
606+
public fun <init> (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V
607+
public synthetic fun <init> (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
604608
public fun getActionName ()Ljava/lang/String;
605609
public fun getActionOwner ()Ljava/lang/String;
606610
public fun getActionVersion ()Ljava/lang/String;
611+
public fun getIntendedVersion ()Ljava/lang/String;
607612
public fun getUsesString ()Ljava/lang/String;
608613
}
609614

github-workflows-kt/src/main/kotlin/io/github/typesafegithub/workflows/domain/actions/Action.kt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,19 @@ public abstract class RegularAction<out OUTPUTS : Outputs>(
2525
public open val actionOwner: String,
2626
public open val actionName: String,
2727
public open val actionVersion: String,
28+
/**
29+
* Can be set if [actionVersion] is set to a commit hash. Then, [intendedVersion] should be set to the version of
30+
* the action corresponding to the commit hash. Thanks to this, the intended version will be used in the resulting
31+
* workflow's YAML, as a hint for humans and automation tools related to version management.
32+
*/
33+
public open val intendedVersion: String? = null,
2834
) : Action<OUTPUTS>() {
35+
public constructor(
36+
actionOwner: String,
37+
actionName: String,
38+
actionVersion: String,
39+
) : this(actionOwner = actionOwner, actionName = actionName, actionVersion = actionVersion, intendedVersion = null)
40+
2941
override val usesString: String
3042
get() = "$actionOwner/$actionName@$actionVersion"
3143
}

github-workflows-kt/src/main/kotlin/io/github/typesafegithub/workflows/domain/actions/CustomAction.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ public data class CustomAction(
1212
override val actionOwner: String,
1313
override val actionName: String,
1414
override val actionVersion: String,
15+
override val intendedVersion: String? = null,
1516
public val inputs: Map<String, String> = emptyMap(),
1617
) : RegularAction<Outputs>(actionOwner, actionName, actionVersion) {
1718
override fun toYamlArguments(): LinkedHashMap<String, String> = LinkedHashMap(inputs)

github-workflows-kt/src/main/kotlin/io/github/typesafegithub/workflows/yaml/ObjectToYaml.kt

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@ package io.github.typesafegithub.workflows.yaml
22

33
import it.krzeminski.snakeyaml.engine.kmp.api.DumpSettings
44
import it.krzeminski.snakeyaml.engine.kmp.api.StreamDataWriter
5+
import it.krzeminski.snakeyaml.engine.kmp.comments.CommentType
6+
import it.krzeminski.snakeyaml.engine.kmp.comments.CommentType.IN_LINE
57
import it.krzeminski.snakeyaml.engine.kmp.common.FlowStyle
68
import it.krzeminski.snakeyaml.engine.kmp.common.ScalarStyle
79
import it.krzeminski.snakeyaml.engine.kmp.emitter.Emitter
10+
import it.krzeminski.snakeyaml.engine.kmp.events.CommentEvent
811
import it.krzeminski.snakeyaml.engine.kmp.events.DocumentEndEvent
912
import it.krzeminski.snakeyaml.engine.kmp.events.DocumentStartEvent
1013
import it.krzeminski.snakeyaml.engine.kmp.events.ImplicitTuple
@@ -23,6 +26,7 @@ internal fun Any.toYaml(): String {
2326
// Otherwise line breaks appear in places that create an incorrect YAML, e.g. in the middle of GitHub
2427
// expressions.
2528
width = Int.MAX_VALUE,
29+
dumpComments = true,
2630
)
2731
val writer =
2832
object : StringWriter(), StreamDataWriter {
@@ -46,6 +50,10 @@ private fun Any?.elementToYaml(emitter: Emitter) {
4650
is Map<*, *> -> this.mapToYaml(emitter)
4751
is List<*> -> this.listToYaml(emitter)
4852
is String, is Int, is Float, is Double, is Boolean, null -> this.scalarToYaml(emitter)
53+
is StringWithComment -> {
54+
this.value.scalarToYaml(emitter)
55+
(" " + this.comment).commentToYaml(emitter)
56+
}
4957
else -> error("Serializing $this is not supported!")
5058
}
5159
}
@@ -96,3 +104,14 @@ private fun Any?.scalarToYaml(emitter: Emitter) {
96104
ScalarEvent(null, null, ImplicitTuple(true, true), this.toString(), scalarStyle),
97105
)
98106
}
107+
108+
private fun String.commentToYaml(emitter: Emitter) {
109+
emitter.emit(
110+
CommentEvent(
111+
commentType = IN_LINE,
112+
value = this,
113+
startMark = null,
114+
endMark = null,
115+
),
116+
)
117+
}

github-workflows-kt/src/main/kotlin/io/github/typesafegithub/workflows/yaml/StepsToYaml.kt

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import io.github.typesafegithub.workflows.domain.Shell.Pwsh
1212
import io.github.typesafegithub.workflows.domain.Shell.Python
1313
import io.github.typesafegithub.workflows.domain.Shell.Sh
1414
import io.github.typesafegithub.workflows.domain.Step
15+
import io.github.typesafegithub.workflows.domain.actions.RegularAction
1516

1617
internal fun List<Step<*>>.stepsToYaml(): List<Map<String, Any?>> = this.map { it.toYaml() }
1718

@@ -28,7 +29,14 @@ private fun ActionStep<*>.toYaml(): Map<String, Any?> =
2829
"name" to name,
2930
"continue-on-error" to continueOnError,
3031
"timeout-minutes" to timeoutMinutes,
31-
"uses" to action.usesString,
32+
"uses" to
33+
this.action.let {
34+
if (it is RegularAction && it.intendedVersion != null) {
35+
StringWithComment(it.usesString, it.intendedVersion!!)
36+
} else {
37+
it.usesString
38+
}
39+
},
3240
"with" to action.toYamlArguments().ifEmpty { null },
3341
"env" to env.ifEmpty { null },
3442
"if" to condition,
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package io.github.typesafegithub.workflows.yaml
2+
3+
internal data class StringWithComment(
4+
val value: String,
5+
val comment: String,
6+
)

github-workflows-kt/src/test/kotlin/io/github/typesafegithub/workflows/IntegrationTest.kt

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ import io.github.typesafegithub.workflows.actions.endbug.AddAndCommit
66
import io.github.typesafegithub.workflows.annotations.ExperimentalKotlinLogicStep
77
import io.github.typesafegithub.workflows.domain.Concurrency
88
import io.github.typesafegithub.workflows.domain.RunnerType
9+
import io.github.typesafegithub.workflows.domain.actions.Action
10+
import io.github.typesafegithub.workflows.domain.actions.Action.Outputs
11+
import io.github.typesafegithub.workflows.domain.actions.RegularAction
912
import io.github.typesafegithub.workflows.domain.triggers.Push
1013
import io.github.typesafegithub.workflows.dsl.expressions.expr
1114
import io.github.typesafegithub.workflows.dsl.workflow
@@ -912,4 +915,57 @@ class IntegrationTest :
912915
""".trimIndent()
913916
targetTempFile.exists() shouldBe false
914917
}
918+
919+
test("action version with comment") {
920+
// when
921+
workflow(
922+
name = "Test workflow",
923+
on = listOf(Push()),
924+
sourceFile = sourceTempFile,
925+
consistencyCheckJobConfig = Disabled,
926+
) {
927+
job(
928+
id = "test_job",
929+
runsOn = RunnerType.UbuntuLatest,
930+
) {
931+
uses(
932+
name = "Check out",
933+
action =
934+
object : RegularAction<Outputs>(
935+
actionOwner = "actions",
936+
actionName = "checkout",
937+
actionVersion = "08c6903cd8c0fde910a37f88322edcfb5dd907a8",
938+
intendedVersion = "v5.0.0",
939+
) {
940+
override fun toYamlArguments(): LinkedHashMap<String, String> =
941+
linkedMapOf("path" to "my-repo")
942+
943+
override fun buildOutputObject(stepId: String): Outputs = Outputs(stepId)
944+
},
945+
)
946+
}
947+
}
948+
949+
// then
950+
targetTempFile.readText() shouldBe
951+
"""
952+
# This file was generated using Kotlin DSL (.github/workflows/some_workflow.main.kts).
953+
# If you want to modify the workflow, please change the Kotlin file and regenerate this YAML file.
954+
# Generated with https://github.com/typesafegithub/github-workflows-kt
955+
956+
name: 'Test workflow'
957+
on:
958+
push: {}
959+
jobs:
960+
test_job:
961+
runs-on: 'ubuntu-latest'
962+
steps:
963+
- id: 'step-0'
964+
name: 'Check out'
965+
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # v5.0.0
966+
with:
967+
path: 'my-repo'
968+
969+
""".trimIndent()
970+
}
915971
})

github-workflows-kt/src/test/kotlin/io/github/typesafegithub/workflows/yaml/ObjectToYamlTest.kt

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,4 +140,24 @@ class ObjectToYamlTest :
140140
141141
""".trimIndent()
142142
}
143+
144+
it("correctly serializes string with comment") {
145+
// given
146+
val objectToSerialize =
147+
mapOf(
148+
"foo" to "bar",
149+
"baz" to StringWithComment("goo", "cool-comment"),
150+
)
151+
152+
// when
153+
val yaml = objectToSerialize.toYaml()
154+
155+
// then
156+
yaml shouldBe
157+
"""
158+
foo: 'bar'
159+
baz: 'goo' # cool-comment
160+
161+
""".trimIndent()
162+
}
143163
})

github-workflows-kt/src/test/kotlin/io/github/typesafegithub/workflows/yaml/StepsToYamlTest.kt

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ import io.github.typesafegithub.workflows.actions.actions.UploadArtifact
88
import io.github.typesafegithub.workflows.domain.ActionStep
99
import io.github.typesafegithub.workflows.domain.CommandStep
1010
import io.github.typesafegithub.workflows.domain.Shell
11+
import io.github.typesafegithub.workflows.domain.actions.Action
1112
import io.github.typesafegithub.workflows.domain.actions.CustomAction
13+
import io.github.typesafegithub.workflows.domain.actions.RegularAction
1214
import io.kotest.core.spec.style.DescribeSpec
1315
import io.kotest.matchers.shouldBe
1416

@@ -460,5 +462,40 @@ class StepsToYamlTest :
460462
),
461463
)
462464
}
465+
466+
it("renders with comment") {
467+
// given
468+
val steps =
469+
listOf(
470+
ActionStep(
471+
id = "someId",
472+
action =
473+
object : RegularAction<Action.Outputs>(
474+
actionOwner = "some-owner",
475+
actionName = "some-name",
476+
actionVersion = "some-commit-hash",
477+
intendedVersion = "some-version",
478+
) {
479+
override fun toYamlArguments(): LinkedHashMap<String, String> =
480+
linkedMapOf("foo" to "bar")
481+
482+
override fun buildOutputObject(stepId: String): Outputs = Outputs(stepId)
483+
},
484+
),
485+
)
486+
487+
// when
488+
val yaml = steps.stepsToYaml()
489+
490+
// then
491+
yaml shouldBe
492+
listOf(
493+
mapOf(
494+
"id" to "someId",
495+
"uses" to StringWithComment("some-owner/some-name@some-commit-hash", "some-version"),
496+
"with" to mapOf("foo" to "bar"),
497+
),
498+
)
499+
}
463500
}
464501
})

0 commit comments

Comments
 (0)