How to Write a GitHub Action in Rust
Creating reusable GitHub Actions is an easy way to automate away everyday tasks in CI/CD. However, actions are typically implemented in TypeScript or JavaScript, and getting started in another language is much more challenging. My favorite language is Rust, so naturally, I wanted an easier way to oxidize my actions.
cargo-generate
Rather than walk through all of the manual steps like I had to, you can use cargo-generate
to get started quickly. From the command line, run cargo-binstall cargo-generate
followed by cargo generate dbanty/rust-github-action-template
, then follow the prompts to fill in the boilerplate values.
Not familiar with
cargo-binstall
? You can use it in place ofcargo install
to install supported binaries rather than compiling them from source! You should make your next Rust binary cargo binstallable!
Finally, you’ll get a fully-functioning GitHub Action implemented in Rust, ready for customization! You can see an example of the output in my Sample Rust Action repo, which is also a GitHub template (in case you want to skip the cargo-generate
step).
Next steps
There’s a “TODO” section in the generated README.md
that gives you a high-level set of next steps—so feel free to dive right in if you’re a hands-on learner! For completeness, I’ll walk through each of the steps here.
First, you’ll want to update the README
to describe what your action does and how to use it. I find it easier to describe the user experience I want to create before I try to create it, a sort of “documentation-driven development”. As an example, you can check out the docs for my GraphQL Check Action.
Now that you’ve designed your action, you need to define your inputs and outputs in action.yml
. Each input needs to be defined in two places:
inputs:
error:
description: "The error message to display, if any"
required: false
default: ""
runs:
using: "docker"
image: "ghcr.io/<your_username>/<your_repo_name>:v1"
args:
- ${{ inputs.error }}
The inputs
section is how you define inputs for the action itself—GitHub will do some validation here, and users might peak at the description to double-check what each input does. Then, in the runs
section, you pass the input to your Rust binary. The order here matters—so it’s easiest to add inputs one at a time.
The outputs
section lets you tell users what they can receive when the action fails. I recommend including, at a minimum, an error
output for easier testing:
outputs:
error:
description: "The description of any error that occurred"
With inputs and outputs defined in action.yml
, you need to consume the inputs and output the outputs! The generated code comes with an example of each:
//! src/main.rs
use std::env;
use std::fs::write;
use std::process::exit;
fn main() {
let github_output_path = env::var("GITHUB_OUTPUT").unwrap();
let args: Vec<String> = env::args().collect();
let error = &args[1];
if !error.is_empty() {
eprintln!("Error: {error}");
write(github_output_path, format!("error={error}")).unwrap();
exit(1);
}
}
Here we can see the list of arguments passed in from the args
section of action.yml
ends up in our args
variable. The first entry in this Vec
is the name of the binary, so the first argument is at index 1. The eprintln!
line is to write a message to standard error—this will appear in the GitHub logs so that users know what happened. The usage of write()
is an example of setting an output—you have to write to a file path which is set to the environment variable GITHUB_OUTPUT
using the format <output_name>=<output_value>
. Then, exit(1)
will make the action fail the workflow (putting that little red x on a status check and preventing PRs from merging).
For more ways you can interact with GitHub Actions (like setting warning messages), I recommend reading https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions.
The last step is to change your default branch to be named v1
. This is because the template assumes you will use semantic versioning and that users will always want the latest compatible release (v1). You can use whatever branching and tagging strategy you want, though you’ll have to alter more of the generated code.
That’s it! If you can handle inputs and outputs and set actions to failed, you’re ready to start implementing basic actions! Just write Rust code like normal—you can even install any dependencies you want!
Testing branches and pull requests
The generated code comes with an included .github/workflows/integration_tests.yml
file for testing the action. If we take a peak inside, we’ll see that it consumes our GitHub action using the uses: ./
syntax:
jobs:
test_success:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: ./
This works just fine on the v1
branch, but if we look back at our action definition, we can see a problem:
runs:
using: "docker"
image: "ghcr.io/<your_username>/<your_repo_name>:v1"
args:
- ${{ inputs.error }}
The action uses the v1
tag of a Docker image built from this repo. That v1
tag corresponds to the v1
Git branch—meaning that if you run these tests on any other branch, you’ll still be testing the v1
branch! The easiest way to override this (e.g., for testing a pull request) is to change that to image: 'Dockerfile'
. This will test the code of whatever branch you are on, but be careful not to commit this change back to v1
or it will cause terrible performance for action consumers.
How it all works
Now that you know how to implement and test your actions, you’re ready to go! But if you’re still curious about how everything works, stick around for a deeper dive.
First, we use the Docker container action method—one of three ways to create GitHub Actions. This enables us to build whatever kind of binary we want, using whatever dependencies we want, without needing JavaScript or complex, dynamic install scripts. There are some limitations, though. Notably, these actions can only run on runners with a Linux operating system, making them less flexible or portable than JavaScript actions. Second, some capabilities may not be possible from your actions (like setting environment variables).
Another limitation of Docker actions is the one I mentioned in the testing section above. You either need to publish a Docker image and pin your action to a specific tag or rebuild the Docker image every time that action runs. Rust can take a long time to compile, especially when waiting for Cargo to download dependencies—so building the image every time makes for a poor experience for the action’s consumers. However, pinning to a tag makes it harder to test multiple branches, a tradeoff we have to accept for now.
So, to get from your Rust code to a consumable action—we have to build a Docker image and publish it to a registry so that the action can pull it before running your code. Let’s take a deeper look at each of these steps.
The template generates a workflow in .github/workflows/docker-publish.yml
that builds a new image with every push to the v1
branch. It’s a rather complicated workflow, so we’ll take a look at a couple of snippets:
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
steps:
# other steps omitted
- name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@v4
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
Here we see that we’re publishing to the ghcr.io
registry with an image named the same as our repository—that enables us to push to GitHub Packages with no additional authentication, all of your artifacts live with your repository! We use the cache-from
and cache-to
inputs to enable Docker layer caching—crucial given how slow Rust-based images can be to build. We’re also passing the input context: .
, which means it should build from a file called Dockerfile
—let’s look at that next!
FROM rust:1.67 as build
# create a new empty shell project
RUN USER=root cargo new --bin sample-rust-action
WORKDIR /sample-rust-action
# copy over your manifests
COPY ./Cargo.lock ./Cargo.lock
COPY ./Cargo.toml ./Cargo.toml
# this build step will cache your dependencies
RUN cargo build --release
RUN rm src/*.rs
# copy your source tree
COPY ./src ./src
# build for release
RUN rm ./target/release/deps/sample_rust_action*
RUN cargo build --release
# our final base
FROM gcr.io/distroless/cc AS runtime
# copy the build artifact from the build stage
COPY /sample-rust-action/target/release/sample-rust-action .
# set the startup command to run your binary
ENTRYPOINT ["/sample-rust-action"]
Going through this line by line, we:
-
Start with the official
rust
image for building—some slimmer images probably work, but this gives us maximum flexibility for template users. -
We create an empty Rust binary with
cargo new
, this is a simple way to get Docker layer caching to work. For a more robust solution, you may want to check out cargo-chef. -
The next few steps build just enough of our code to get dependencies to cache. Note that modifying
Cargo.lock
orCargo.toml
will bust the cache; this is partially whycargo-chef
may be a better option. -
The next couple of steps (starting with
COPY ./src ./src
and going throughRUN cargo build --release
) will build our finished binary -
Now, we switch over to a smaller base image. You can make this even smaller by switching to
gcr.io/distroless/static
but it makes building harder (you have to use some musl toolchain stuff), and I found that it doesn’t make the action any faster. -
We pop our binary over into the fresh
cc
image, and set up the entry point (note thatCMD
doesn’t work here; you have to useENTRYPOINT
). That’s the whole image!
Once we’ve built and published the image, we immediately test it to catch any last-minute problems. Let’s look back at the .github/workflows/integration_tests.yml
file from earlier:
name: Test consuming this action
on:
pull_request:
branches: [v1]
workflow_run:
workflows: ["Docker Publish"]
branches: [v1]
types:
- completed
jobs:
test_success:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: ./
test_error:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- id: test
continue-on-error: true
uses: ./
with:
error: "This is an error"
- name: Verify failure
if: steps.test.outputs.error != ''
run: echo "Failed as expected"
- name: Unexpected success
if: steps.test.outputs.error == ''
run: echo "Succeeded unexpectedly" && exit 1
The workflow_run
section tells this action to run after we publish to Docker—this ensures we’re testing the version we just published and not an earlier variant. Then, the workflow comes with two tests as examples—you’ll want to replace these with ones that exercise the actual inputs and outputs. The second job, test_error
, is much more interesting—this is how you can test failure conditions (and why we set the error output). It’s just as important to test expected failures as expected successes, maybe even more important!
Conclusion
My little cargo-generate
template will hopefully make it easier than ever to write Rust-based GitHub actions. If you try it out and have any suggestions or questions, please open an issue on the repository. If you want to hear more about the motivation for this template—why I’m writing actions in Rust instead of TypeScript, follow me for that upcoming post!
Was this post super helpful to you? Tip me on GitHub, Patreon, or Ko-Fi.
Have a question or comment about this post? Leave it in the discussion thread on GitHub!
Want to be notified of future posts? Watch releases in the GitHub repo or follow me on Mastodon
Have an idea or request for a future blog topic? Drop it in the GitHub discussions under ideas.