Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,18 @@
// Always untyped action.
@file:DependsOn("typesafegithub:always-untyped-action-for-tests:v1")

// Action version pinned to a commit.
// Action version pinned to an explicit commit.
@file:DependsOn("actions:setup-python___commit_lenient:v6.0.0__e797f83bcb11b83ae66e0230d6156d7c80228e7c")

// Action version pinned to a commit.
@file:DependsOn("actions:github-script___commit:v9.0.0")

import io.github.typesafegithub.workflows.actions.actions.Cache
import io.github.typesafegithub.workflows.actions.actions.Checkout
import io.github.typesafegithub.workflows.actions.actions.Checkout_Untyped
import io.github.typesafegithub.workflows.actions.actions.GithubScript
import io.github.typesafegithub.workflows.actions.actions.SetupNode
import io.github.typesafegithub.workflows.actions.actions.SetupPython
import io.github.typesafegithub.workflows.actions.actions.Checkout_Untyped
import io.github.typesafegithub.workflows.actions.gradle.ActionsSetupGradle
import io.github.typesafegithub.workflows.actions.gradle.ActionsDependencySubmission_Untyped
import io.github.typesafegithub.workflows.actions.gradle.ActionsWrapperValidation
Expand All @@ -53,3 +57,4 @@ ActionsWrapperValidation().actionVersion shouldBe "v4.2"
Checkout(fetchTags = false).copy(fetchTags = true)

SetupPython().actionVersion shouldBe "e797f83bcb11b83ae66e0230d6156d7c80228e7c"
GithubScript(script = "").actionVersion shouldBe "3a2844b7e9c422d3c10d287c895573f7108da1b3"
1 change: 1 addition & 0 deletions action-binding-generator/api/action-binding-generator.api
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ public final class io/github/typesafegithub/workflows/actionbindinggenerator/dom
}

public final class io/github/typesafegithub/workflows/actionbindinggenerator/domain/SignificantVersion : java/lang/Enum {
public static final field COMMIT Lio/github/typesafegithub/workflows/actionbindinggenerator/domain/SignificantVersion;
public static final field COMMIT_LENIENT Lio/github/typesafegithub/workflows/actionbindinggenerator/domain/SignificantVersion;
public static final field FULL Lio/github/typesafegithub/workflows/actionbindinggenerator/domain/SignificantVersion;
public static final field MAJOR Lio/github/typesafegithub/workflows/actionbindinggenerator/domain/SignificantVersion;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package io.github.typesafegithub.workflows.actionbindinggenerator.domain

import io.github.typesafegithub.workflows.actionbindinggenerator.domain.SignificantVersion.COMMIT
import io.github.typesafegithub.workflows.actionbindinggenerator.domain.SignificantVersion.COMMIT_LENIENT
import io.github.typesafegithub.workflows.actionbindinggenerator.domain.SignificantVersion.FULL

Expand Down Expand Up @@ -38,7 +39,11 @@ public val ActionCoords.prettyPrint: String get() = "$prettyPrintWithoutVersion@
public val ActionCoords.prettyPrintWithoutVersion: String get() = "$owner/$fullName${
significantVersion.takeUnless { it == FULL }?.let { " with $it version" } ?: ""
}${
if ((significantVersion == COMMIT_LENIENT) && (comment != null)) " and comment '$comment'" else ""
if (((significantVersion == COMMIT_LENIENT) || (significantVersion == COMMIT)) && (comment != null)) {
" and comment '$comment'"
} else {
""
}
}"

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,15 @@ public enum class SignificantVersion {
MINOR,

/**
* Write the commit ID as the version to the generated YAML.
* Write the given commit ID as the version to the generated YAML.
*/
COMMIT_LENIENT,

/**
* Write the commit ID as the version to the generated YAML.
*/
COMMIT,

/**
* Write the full version to the generated YAML.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import com.squareup.kotlinpoet.buildCodeBlock
import io.github.typesafegithub.workflows.actionbindinggenerator.domain.ActionCoords
import io.github.typesafegithub.workflows.actionbindinggenerator.domain.ActionTypings
import io.github.typesafegithub.workflows.actionbindinggenerator.domain.MetadataRevision
import io.github.typesafegithub.workflows.actionbindinggenerator.domain.SignificantVersion.COMMIT
import io.github.typesafegithub.workflows.actionbindinggenerator.domain.SignificantVersion.COMMIT_LENIENT
import io.github.typesafegithub.workflows.actionbindinggenerator.domain.SignificantVersion.FULL
import io.github.typesafegithub.workflows.actionbindinggenerator.domain.SignificantVersion.MAJOR
Expand Down Expand Up @@ -466,7 +467,7 @@ private fun TypeSpec.Builder.inheritsFromRegularAction(
when (coords.significantVersion) {
MAJOR -> coords.version.majorVersion
MINOR -> coords.version.minorVersion
COMMIT_LENIENT, FULL -> coords.version
COMMIT_LENIENT, COMMIT, FULL -> coords.version
},
).also {
if (coords.comment != null) {
Expand Down
25 changes: 24 additions & 1 deletion docs/user-guide/using-actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ To add a dependency on an action:

2. Add a dependency on a Maven artifact, e.g. for `actions/checkout@v3` the right way to add the dependency in the
script is: `@file:DependsOn("actions:checkout:v3")`. As you can see, the group ID was adopted to model the action's
owner, the artifact ID models the action name, and the version is just action's version (a tag or a branch
owner, the artifact ID models the action name, and the version is just the action's version (a tag or a branch
corresponding to a released version). If an action's manifest is defined in a subdirectory, like the popular
`gradle/actions/setup-gradle@v3`, replace the slashes in the action name with `__`, so in this case it would be
`@file:DependsOn("gradle:actions__setup-gradle:v3")`.
Expand Down Expand Up @@ -54,6 +54,29 @@ To add a dependency on an action:
`[v3,v4)`. In such a case - or always, to be on the safe side - you might want to change the range to
`[v3,v4-alpha)`, as the `alpha` version is the lowest possible version in Maven semantics.

??? tip "Pinning actions to a commit hash"
Past supply-chain attacks where action version tags or branches were repointed to different commits,
either broken ones or malicious ones, or just gone for good, showed that it might be a good idea to instead
pin used actions to a specific commit hash.

This project supports pinning to a commit hash in two ways.

Like with `___major` and `___minor` suffixes, described above, you can use the suffix `___commit` to pin the
used action to the commit of the given version. Do **not** combine this with an intentionally changing version,
so do not use it with `v4` or `v4.1` or a version range, but always just with a concrete version that will not
change over time, like `v4.0.0`. The generated YAML will contain the commit hash and thus continue using that
state even if the version points somewhere else. With the consistency check enabled (the default), you will
be notified if the version points to a different commit than the one used in the generated YAML as the job will
fail, so you can notify the action maintainer about the potential attack.

Additionally, you can use any arbitrary commit hash by using the suffix `___commit_lenient`. With this suffix,
the version also becomes two-part, separated by two underscores. The first part is the version where the typings
will be used from in the typing catalog if the action does not contain typings natively, and it will be put in
the comment after the commit hash. The second part is the commit hash itself. So the version would look like
`v4.0.0__abcdef1234567890abcdef1234567890abcdef12`. The lenient part is, that there is no check at any time that
the version and the commit match in any way. This way you can also use any state of the given action, as long as
the commit contains the action code and you are not restricted to actually labeled commits.

3. Use the action by importing a class like `io.github.typesafegithub.workflows.actions.actions.Checkout`.

For every action, a binding will be generated. However, some less popular actions don't have typings configured for
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,12 @@ fun main() {
}

fun Application.appModule(
buildVersionArtifacts: suspend (BindingsServerRequest, HttpClient) -> VersionArtifacts?,
buildVersionArtifacts: suspend (
BindingsServerRequest,
String,
MeterRegistry,
HttpClient,
) -> VersionArtifacts?,
buildPackageArtifacts: suspend (
BindingsServerRequest,
String,
Expand All @@ -90,7 +95,7 @@ fun Application.appModule(
execute(request)
}
}
val bindingsCache = buildBindingsCache(buildVersionArtifacts, httpClient)
val bindingsCache = buildBindingsCache(buildVersionArtifacts, getGithubAuthToken, httpClient)
val metadataCache = buildMetadataCache(bindingsCache, buildPackageArtifacts, getGithubAuthToken)
installPlugins(prometheusRegistry)

Expand All @@ -103,14 +108,29 @@ fun Application.appModule(
}

private fun buildBindingsCache(
buildVersionArtifacts: suspend (BindingsServerRequest, HttpClient) -> VersionArtifacts?,
buildVersionArtifacts: suspend (
BindingsServerRequest,
String,
MeterRegistry,
HttpClient,
) -> VersionArtifacts?,
getGithubAuthToken: () -> String,
httpClient: HttpClient,
): LoadingCache<BindingsServerRequest, CachedVersionArtifact> =
Caffeine
.newBuilder()
.refreshAfterWrite(1.hours)
.recordStats()
.asLoadingCache { Optional.ofNullable(buildVersionArtifacts(it, httpClient)) }
.asLoadingCache {
Optional.ofNullable(
buildVersionArtifacts(
it,
getGithubAuthToken(),
prometheusRegistry,
httpClient,
),
)
}

@Suppress("ktlint:standard:function-signature") // Conflict with detekt.
private fun buildMetadataCache(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import io.ktor.http.HttpStatusCode
import io.ktor.server.testing.testApplication
import io.micrometer.core.instrument.MeterRegistry
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
Expand All @@ -26,7 +27,7 @@ class ArtifactRoutesTest :
// Given
application {
appModule(
buildVersionArtifacts = { _, _ ->
buildVersionArtifacts = { _, _, _, _ ->
VersionArtifacts(
files = mapOf("some-action-v4.pom" to TextArtifact { "Some POM contents" }),
typingActualSource = TypingActualSource.TYPING_CATALOG,
Expand All @@ -52,7 +53,7 @@ class ArtifactRoutesTest :
// Given
application {
appModule(
buildVersionArtifacts = { _, _ -> null },
buildVersionArtifacts = { _, _, _, _ -> null },
// Irrelevant for these tests.
buildPackageArtifacts = { _, _, _, _ -> emptyMap() },
getGithubAuthToken = { "" },
Expand All @@ -70,8 +71,9 @@ class ArtifactRoutesTest :
test("when artifacts is not available, two requests in a row") {
testApplication {
// Given
val mockBuildVersionArtifacts = mockk<(BindingsServerRequest, HttpClient) -> VersionArtifacts?>()
every { mockBuildVersionArtifacts(any(), any()) } returns null
val mockBuildVersionArtifacts =
mockk<(BindingsServerRequest, String, MeterRegistry, HttpClient) -> VersionArtifacts?>()
every { mockBuildVersionArtifacts(any(), any(), any(), any()) } returns null
application {
appModule(
buildVersionArtifacts = mockBuildVersionArtifacts,
Expand All @@ -93,7 +95,7 @@ class ArtifactRoutesTest :

// The fact that the resource doesn't exist is cached, and the
// resource generation logic isn't called in the second request.
verify(exactly = 1) { mockBuildVersionArtifacts(any(), any()) }
verify(exactly = 1) { mockBuildVersionArtifacts(any(), any(), any(), any()) }
}
}

Expand All @@ -102,7 +104,7 @@ class ArtifactRoutesTest :
// Given
application {
appModule(
buildVersionArtifacts = { _, _ -> error("An internal error occurred!") },
buildVersionArtifacts = { _, _, _, _ -> error("An internal error occurred!") },
// Irrelevant for these tests.
buildPackageArtifacts = { _, _, _, _ -> emptyMap() },
getGithubAuthToken = { "" },
Expand All @@ -120,8 +122,9 @@ class ArtifactRoutesTest :
test("when binding generation fails and then succeeds, and two requests are made") {
testApplication {
// Given
val mockBuildVersionArtifacts = mockk<(BindingsServerRequest, HttpClient) -> VersionArtifacts?>()
every { mockBuildVersionArtifacts(any(), any()) } throws
val mockBuildVersionArtifacts =
mockk<(BindingsServerRequest, String, MeterRegistry, HttpClient) -> VersionArtifacts?>()
every { mockBuildVersionArtifacts(any(), any(), any(), any()) } throws
Exception("An internal error occurred!") andThen
VersionArtifacts(
files = mapOf("some-action-v4.pom" to TextArtifact { "Some POM contents" }),
Expand All @@ -146,7 +149,7 @@ class ArtifactRoutesTest :
// Then
response2.status shouldBe HttpStatusCode.OK

verify(exactly = 2) { mockBuildVersionArtifacts(any(), any()) }
verify(exactly = 2) { mockBuildVersionArtifacts(any(), any(), any(), any()) }
}
}

Expand All @@ -155,9 +158,11 @@ class ArtifactRoutesTest :
// Given
application {
appModule(
buildVersionArtifacts = { bindingsServerRequest, _ ->
buildVersionArtifacts = { bindingsServerRequest, _, _, _ ->
buildVersionArtifacts(
bindingsServerRequest,
"",
mockk(),
HttpClient(
MockEngine { request ->
when (request.url.toString()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ class MetadataRoutesTest :
},
getGithubAuthToken = { "some-token" },
// Irrelevant for these tests.
buildVersionArtifacts = { _, _ ->
buildVersionArtifacts = { _, _, _, _ ->
VersionArtifacts(
files = emptyMap(),
typingActualSource = null,
Expand Down Expand Up @@ -54,7 +54,7 @@ class MetadataRoutesTest :
},
getGithubAuthToken = { "some-token" },
// Irrelevant for these tests.
buildVersionArtifacts = { _, _ ->
buildVersionArtifacts = { _, _, _, _ ->
VersionArtifacts(
files = emptyMap(),
typingActualSource = null,
Expand All @@ -81,7 +81,7 @@ class MetadataRoutesTest :
},
getGithubAuthToken = { "some-token" },
// Irrelevant for these tests.
buildVersionArtifacts = { _, _ ->
buildVersionArtifacts = { _, _, _, _ ->
VersionArtifacts(
files = emptyMap(),
typingActualSource = null,
Expand Down Expand Up @@ -118,7 +118,7 @@ class MetadataRoutesTest :
buildPackageArtifacts = mockBuildPackageArtifacts,
getGithubAuthToken = { "some-token" },
// Irrelevant for these tests.
buildVersionArtifacts = { _, _ ->
buildVersionArtifacts = { _, _, _, _ ->
VersionArtifacts(
files = emptyMap(),
typingActualSource = null,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package io.github.typesafegithub.workflows.mavenbinding

import io.github.typesafegithub.workflows.actionbindinggenerator.domain.SignificantVersion.COMMIT
import io.github.typesafegithub.workflows.actionbindinggenerator.domain.TypingActualSource
import io.github.typesafegithub.workflows.shared.internal.fetchVersionSha
import io.ktor.client.HttpClient
import io.micrometer.core.instrument.MeterRegistry

sealed interface Artifact

Expand All @@ -20,8 +23,28 @@ data class VersionArtifacts(

suspend fun buildVersionArtifacts(
bindingsServerRequest: BindingsServerRequest,
githubAuthToken: String,
meterRegistry: MeterRegistry,
httpClient: HttpClient,
): VersionArtifacts? {
val bindingsServerRequest =
if (bindingsServerRequest.actionCoords.significantVersion == COMMIT) {
bindingsServerRequest.copy(
actionCoords =
bindingsServerRequest.actionCoords.copy(
version =
fetchVersionSha(
"${bindingsServerRequest.actionCoords.owner}/${bindingsServerRequest.actionCoords.name}",
bindingsServerRequest.actionCoords.version,
githubAuthToken,
meterRegistry,
).getOrNull() ?: return null,
comment = bindingsServerRequest.actionCoords.version,
),
)
} else {
bindingsServerRequest
}
with(bindingsServerRequest) {
val jars = actionCoords.buildJars(httpClient = httpClient) ?: return null
val pom = this.buildPomFile()
Expand Down
Loading
Loading