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

Issue:
Implement fallback for using typings for older major version
Let's say the action doesn't provide typings, and we maintain typings for v2 and v3. When v4 comes, in practice it's often the case that we can reuse the typings from v3. If some inputs are missing, we could default to string.

Pull request description:
Fixes #1073.

If there's no typings for a given version in the catalog, typings for an older version is tried to be fetched. Thanks to this, we cover a scenario where usually the next version of some action doesn't need to have typings defined immediately because the older version is fine. It doesn't account for breaking changes where this approach will produce an incorrect binding, however in practice it should be better than what we have now.

It doesn't hold for action-hosted typings.

Patch:
diff --git a/action-binding-generator/src/main/kotlin/io/github/typesafegithub/workflows/actionbindinggenerator/TypesProviding.kt b/action-binding-generator/src/main/kotlin/io/github/typesafegithub/workflows/actionbindinggenerator/TypesProviding.kt
index b4f17e25c0..b6245b7481 100644
--- a/action-binding-generator/src/main/kotlin/io/github/typesafegithub/workflows/actionbindinggenerator/TypesProviding.kt
+++ b/action-binding-generator/src/main/kotlin/io/github/typesafegithub/workflows/actionbindinggenerator/TypesProviding.kt
@@ -3,6 +3,7 @@ package io.github.typesafegithub.workflows.actionbindinggenerator
 import com.charleskorn.kaml.Yaml
 import io.github.typesafegithub.workflows.actionbindinggenerator.TypingActualSource.ACTION
 import io.github.typesafegithub.workflows.actionbindinggenerator.TypingActualSource.TYPING_CATALOG
+import kotlinx.serialization.Serializable
 import kotlinx.serialization.decodeFromString
 import java.io.IOException
 import java.net.URI
@@ -26,6 +27,10 @@ private fun ActionCoords.actionTypesFromCatalog() =
     "https://raw.githubusercontent.com/typesafegithub/github-actions-typing-catalog/" +
         "main/typings/$owner/$repoName/$version/$subName/action-types.yml"
 
+private fun ActionCoords.catalogMetadata() =
+    "https://raw.githubusercontent.com/typesafegithub/github-actions-typing-catalog/" +
+        "main/typings/$owner/$repoName/metadata.yml"
+
 private fun ActionCoords.actionTypesYamlUrl(gitRef: String) =
     "https://raw.githubusercontent.com/$owner/$repoName/$gitRef/$subName/action-types.yaml"
 
@@ -54,7 +59,34 @@ private fun ActionCoords.fetchTypingMetadata(
 }
 
 private fun ActionCoords.fetchFromTypingsFromCatalog(fetchUri: (URI) -> String = ::fetchUri): Pair<ActionTypes, TypingActualSource>? =
-    fetchTypingsFromUrl(url = actionTypesFromCatalog(), fetchUri = fetchUri)?.let { Pair(it, TYPING_CATALOG) }
+    (
+        fetchTypingsFromUrl(url = actionTypesFromCatalog(), fetchUri = fetchUri)
+            ?: fetchTypingsForOlderVersionFromCatalog(fetchUri = fetchUri)
+    )
+        ?.let { Pair(it, TYPING_CATALOG) }
+
+private fun ActionCoords.fetchTypingsForOlderVersionFromCatalog(fetchUri: (URI) -> String): ActionTypes? {
+    val metadataUrl = this.catalogMetadata()
+    val metadataYml =
+        try {
+            println("  ... metadata from $metadataUrl")
+            fetchUri(URI(metadataUrl))
+        } catch (e: IOException) {
+            return null
+        }
+    val metadata = myYaml.decodeFromString<CatalogMetadata>(metadataYml)
+    val fallbackVersion =
+        metadata.versionsWithTypings
+            .filter { it.versionToInt() < this.version.versionToInt() }
+            .maxByOrNull { it.versionToInt() }
+            ?: run {
+                println("  ... no fallback version found!")
+                return null
+            }
+    println("  ... using fallback version: $fallbackVersion")
+    val adjustedCoords = this.copy(version = fallbackVersion)
+    return fetchTypingsFromUrl(url = adjustedCoords.actionTypesFromCatalog(), fetchUri = fetchUri)
+}
 
 private fun fetchTypingsFromUrl(
     url: String,
@@ -118,3 +150,10 @@ private inline fun <reified T> Yaml.decodeFromStringOrDefaultIfEmpty(
     } else {
         default
     }
+
+private fun String.versionToInt() = lowercase().removePrefix("v").toInt()
+
+@Serializable
+private data class CatalogMetadata(
+    val versionsWithTypings: List<String>,
+)


Test patch:
diff --git a/action-binding-generator/src/test/kotlin/io/github/typesafegithub/workflows/actionbindinggenerator/TypesProvidingTest.kt b/action-binding-generator/src/test/kotlin/io/github/typesafegithub/workflows/actionbindinggenerator/TypesProvidingTest.kt
index 55d4c9f527..e87b43e859 100644
--- a/action-binding-generator/src/test/kotlin/io/github/typesafegithub/workflows/actionbindinggenerator/TypesProvidingTest.kt
+++ b/action-binding-generator/src/test/kotlin/io/github/typesafegithub/workflows/actionbindinggenerator/TypesProvidingTest.kt
@@ -63,9 +63,37 @@ class TypesProvidingTest : FunSpec({
     }
 
     context("order of using typings from various sources") {
-        val hostedByActionYml = "inputs:\n  hosted-by-action-yml:\n    type: string"
-        val hostedByActionYaml = "inputs:\n  hosted-by-action-yaml:\n    type: string"
-        val storedInTypingCatalog = "inputs:\n  stored-in-typing-catalog:\n    type: string"
+        val hostedByActionYml =
+            """
+            inputs:
+              hosted-by-action-yml:
+                type: string
+            """.trimIndent()
+        val hostedByActionYaml =
+            """
+            inputs:
+              hosted-by-action-yaml:
+                type: string
+            """.trimIndent()
+        val storedInTypingCatalog =
+            """
+            inputs:
+              stored-in-typing-catalog:
+                type: string
+            """.trimIndent()
+        val metadata =
+            """
+            "versionsWithTypings":
+            - "v2"
+            - "v3"
+            - "v4"
+            """.trimIndent()
+        val storedInTypingCatalogForOlderVersion =
+            """
+            inputs:
+              stored-in-typing-catalog-for-older-version:
+                type: string
+            """.trimIndent()
 
         test("only hosted by the action (.yml)") {
             // Given
@@ -166,6 +194,53 @@ class TypesProvidingTest : FunSpec({
             types shouldBe Pair(mapOf("hosted-by-action-yml" to StringTyping), TypingActualSource.ACTION)
         }
 
+        test("only stored in typing catalog for older version") {
+            // Given
+            val fetchUri: (URI) -> String = {
+                when (it) {
+                    URI(
+                        "https://raw.githubusercontent.com/typesafegithub/github-actions-typing-catalog/" +
+                            "main/typings/some-owner/some-name/metadata.yml",
+                    ),
+                    -> metadata
+                    URI(
+                        "https://raw.githubusercontent.com/typesafegithub/github-actions-typing-catalog/" +
+                            "main/typings/some-owner/some-name/v4//action-types.yml",
+                    ),
+                    -> storedInTypingCatalogForOlderVersion
+                    else -> throw IOException()
+                }
+            }
+            val actionCoord = ActionCoords("some-owner", "some-name", "v6")
+
+            // When
+            val types = actionCoord.provideTypes(metadataRevision = CommitHash("some-hash"), fetchUri = fetchUri)
+
+            // Then
+            types shouldBe Pair(mapOf("stored-in-typing-catalog-for-older-version" to StringTyping), TypingActualSource.TYPING_CATALOG)
+        }
+
+        test("metadata available but no version available") {
+            // Given
+            val fetchUri: (URI) -> String = {
+                when (it) {
+                    URI(
+                        "https://raw.githubusercontent.com/typesafegithub/github-actions-typing-catalog/" +
+                            "main/typings/some-owner/some-name/metadata.yml",
+                    ),
+                    -> metadata
+                    else -> throw IOException()
+                }
+            }
+            val actionCoord = ActionCoords("some-owner", "some-name", "v1")
+
+            // When
+            val types = actionCoord.provideTypes(metadataRevision = CommitHash("some-hash"), fetchUri = fetchUri)
+
+            // Then
+            types shouldBe Pair(emptyMap(), null)
+        }
+
         test("no typings at all") {
             // Given
             val fetchUri: (URI) -> String = { throw IOException() }