Skip to content

Using actions

As a reminder, to be able to use an action, you have to know its owner, name and version, e.g. actions/checkout@v3. You can use any action you want. Read on to learn about your options.

Maven-compatible action bindings repository

When to use this approach

This is the recommended, default approach. Start with this.

To add a dependency on an action:

  1. If you haven't already, add a dependency on a Maven repository that generates the action bindings on the fly: @file:Repository("https://bindings.krzeminski.it").

  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 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").

    Dealing with stale Maven cache, a.k.a. using version ranges

    Additionally, the name part can have the suffix ___major or ___minor (three leading underscores). Without these suffixes if you request a version v1.2.3, the generated YAML will also use exactly v1.2.3 unless you use a custom version override. With the ___major suffix, it would only write v1 to the generated YAML, with the ___minor suffix - v1.2.

    This is especially useful when combined with a version range. The problem with using v1 or v1.2 is that for GitHub Actions, these are changing tags or changing branches and not static releases. In the Maven world, however, a version that does not end with -SNAPSHOT is considered immutable and is not expected to change. This means that if a new version of the action is released that adds a new input, you cannot use it easily as you still have the old v1 artifact in your Maven cache and it will not be updated usually, even though the binding server provides a new binding including the added input. And even if you remove the old version from the Maven cache and get a new version from the bindings server, other people might also have this outdated version in their Maven cache and then fail compilation with your changes. It's worth emphasizing that this problem is currently present only when iterating on your workflows locally. When running on GitHub Actions, this problem doesn't exist because the state of Maven Local repo isn't cached between the runs.

    To mitigate this problem, you can for example use a dependency like gradle:actions__setup-gradle___major:[v3,v4) (from v3 inclusive to v4 exclusive). This will resolve to the latest v3.x.y version and thus include any newly added inputs, but still only write v3 to the YAML. Without the ___major suffix or a not semantically matching range like [v3,v5) or even [v3,v4], you will get problems with the consistency check as then the YAML output changes as soon as a new version is released. For a minor version you would accordingly use the ___minor suffix together with a range like [v4.0,v4.1) to get the latest v4.0 release if the action in question provides such a tag or branch.

    Info

    If an action maintainer provides pre-releases that follow certain naming conventions as documented in the Maven Documentation, you might need to adjust the upper bound. For exmple a version v4.0-beta is less than v4 and thus part of the range [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.

  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 their inputs, so by default all inputs are of type String, have the suffix _Untyped, and additionally the class name will have an _Untyped suffix. The nullability of the inputs will be according to their required status.

There are two ways of configuring typings:

  1. Recommended: a typing manifest (action-typing.yml) in the action's repo, see github-actions-typing. Thanks to this, the action's owner is responsible for providing and maintaining the typings defined in a technology-agnostic way, to be used not only with this Kotlin library. There are also no synchronization issues between the action itself and its typings. When trying to use a new action that has no typings, always discuss this approach with the action owner first.

  2. Fallback: if it's not possible to host the typings with the action, use github-actions-typing-catalog, a community-maintained place to host the typings. You can contribute or fix typings for your favorite action by sending a PR.

Once there are any typings in place for the action, the _Untyped suffixed class is marked @Deprecated, and a class without that suffix is created additionally. In that class for each input that does not have type information available there will still be only the property with _Untyped suffix and nullability according to required status. For each input that does have type information available, there will still be the _Untyped property and additionally a properly typed property. Both of these properties will be nullable. It is a runtime error to set both of these properties as well as setting none if the input is required. The _Untyped properties are not marked @Deprecated, as it could still make sense to use them, for example if you want to set the value from a GitHub Actions expression.

This approach supports dependency updating bots that support Kotlin Script's .main.kts files. E.g. Renovate is known to support it.

User-defined actions

If you are in a hurry and adding typings is not possible right now, browse these options.

Typed binding

When to use this approach

It lets you create an action binding in a similar manner that is provided by the action bindings server i.e. a class that takes some constructor arguments with types of your choice, and maps them to strings inside toYamlArguments. Use it to have better type-safety when using the binding.

Repository based actions

In case of a repository based action which most GitHub actions are, inherit from RegularAction and in case of actions without explicit outputs, use the Actions.Outputs class as type argument:

class MyCoolActionV3(
    private val someArgument: String,
) : RegularAction<Action.Outputs>("acmecorp", "cool-action", "v3") {
    override fun toYamlArguments() =
        linkedMapOf(
            "some-argument" to someArgument,
        )

    override fun buildOutputObject(stepId: String) = Outputs(stepId)
}

or, in case of actions with explicit outputs, create a subclass of Action.Outputs for the type argument:

class MyCoolActionV3(
    private val someArgument: String,
) : RegularAction<MyCoolActionV3.Outputs>("acmecorp", "cool-action", "v3") {
    override fun toYamlArguments() =
        linkedMapOf(
            "some-argument" to someArgument,
        )

    override fun buildOutputObject(stepId: String) = Outputs(stepId)

    class Outputs(
        stepId: String,
    ) : Action.Outputs(stepId) {
        public val coolOutput: String = "steps.$stepId.outputs.coolOutput"
    }
}

Once you've got your action, it's now as simple as using it like this:

uses(
    name = "FooBar",
    action = MyCoolActionV3(someArgument = "foobar"),
)

Local actions

In case of a local action you have available in your repository or cloned from a private repository, inherit from LocalAction instead:

class MyCoolLocalActionV3(
    private val someArgument: String,
) : LocalAction<Action.Outputs>("./.github/actions/cool-action") {
    override fun toYamlArguments() =
        linkedMapOf(
            "some-argument" to someArgument,
        )

    override fun buildOutputObject(stepId: String) = Outputs(stepId)
}

Published Docker actions

In case of a published Docker action, inherit from DockerAction instead:

class MyCoolDockerActionV3(
    private val someArgument: String,
) : DockerAction<Action.Outputs>("alpine", "latest") {
    override fun toYamlArguments() =
        linkedMapOf(
            "some-argument" to someArgument,
        )

    override fun buildOutputObject(stepId: String) = Outputs(stepId)
}

Untyped binding

When to use this approach

It omits typing entirely, and both inputs and outputs are referenced using strings. Use it if you don't care about types because you're in the middle of experimenting. It's also more convenient to produce such code by a code generator.

Repository based actions

In case of a repository based action which most GitHub actions are, use a CustomAction:

val customAction =
    CustomAction(
        actionOwner = "xu-cheng",
        actionName = "latex-action",
        actionVersion = "v2",
        inputs =
            mapOf(
                "root_file" to "report.tex",
                "compiler" to "latexmk",
            ),
    )

If your custom action has outputs, you can access them, albeit in a type-unsafe manner:

job(id = "test_job", runsOn = RunnerType.UbuntuLatest) {
    val customActionStep =
        uses(
            name = "Some step with output",
            action = customAction,
        )

    // use your outputs:
    println(expr(customActionStep.outputs["custom-output"]))
}

Local actions

In case of a local action you have available in your repository or cloned from a private repository, use a CustomLocalAction instead:

val customAction =
    CustomLocalAction(
        actionPath = "./.github/actions/setup-build-env",
    )

Published Docker actions

In case of a published Docker action, use a CustomDockerAction instead:

val customAction =
    CustomDockerAction(
        actionImage = "alpine",
        actionTag = "latest",
    )