Skip to content

Commit d4776b7

Browse files
committed
feat: How to Write a GitHub Action in Rust
Closes: #202
1 parent e23b436 commit d4776b7

File tree

7 files changed

+242
-3
lines changed

7 files changed

+242
-3
lines changed
232 KB
Binary file not shown.
21.2 KB
Loading
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
+++
2+
title = "How to Write a GitHub Action in Rust"
3+
date = "2023-02-06"
4+
5+
[extra]
6+
featured_image = "cover.webp"
7+
featured_image_alt = "The GitHub Octocat logo (a cat with octopus tentacles), followed by a plus symbol, followed by Ferris, the Rust mascot (a happy orange crab)."
8+
discussion = 204
9+
10+
[taxonomies]
11+
tags = ["Rust", "developer-experience"]
12+
+++
13+
14+
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.
15+
16+
## cargo-generate
17+
18+
Rather than walk through all of the manual steps like I had to, you can use [`cargo-generate`](https://crates.io/crates/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.
19+
20+
> Not familiar with [`cargo-binstall`](https://crates.io/crates/cargo-binstall) ? You can use it in place of `cargo install` to install supported binaries rather than compiling them from source! You should make *your* next Rust binary cargo binstallable!
21+
22+
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](https://github.com/dbanty/sample-rust-action) repo, which is also a GitHub template (in case you want to skip the `cargo-generate` step).
23+
24+
## Next steps
25+
26+
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.
27+
28+
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](https://github.com/dbanty/graphql-check-action).
29+
30+
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:
31+
32+
```yaml
33+
inputs:
34+
error:
35+
description: 'The error message to display, if any'
36+
required: false
37+
default: ''
38+
runs:
39+
using: 'docker'
40+
image: 'ghcr.io/<your_username>/<your_repo_name>:v1'
41+
args:
42+
- ${{ inputs.error }}
43+
```
44+
45+
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.
46+
47+
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:
48+
49+
```yaml
50+
outputs:
51+
error:
52+
description: 'The description of any error that occurred'
53+
```
54+
55+
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:
56+
57+
```rust
58+
//! src/main.rs
59+
use std::env;
60+
use std::fs::write;
61+
use std::process::exit;
62+
63+
fn main() {
64+
let github_output_path = env::var("GITHUB_OUTPUT").unwrap();
65+
66+
let args: Vec<String> = env::args().collect();
67+
let error = &args[1];
68+
69+
if !error.is_empty() {
70+
eprintln!("Error: {error}");
71+
write(github_output_path, format!("error={error}")).unwrap();
72+
exit(1);
73+
}
74+
}
75+
```
76+
77+
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).
78+
79+
> 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](https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions).
80+
81+
The last step is to [change your default branch](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-branches-in-your-repository/changing-the-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.
82+
83+
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!
84+
85+
## Testing branches and pull requests
86+
87+
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:
88+
89+
```yaml
90+
jobs:
91+
test_success:
92+
runs-on: ubuntu-latest
93+
steps:
94+
- uses: actions/checkout@v3
95+
- uses: ./
96+
```
97+
98+
This works just fine on the `v1` branch, but if we look back at our action definition, we can see a problem:
99+
100+
```yaml
101+
runs:
102+
using: 'docker'
103+
image: 'ghcr.io/<your_username>/<your_repo_name>:v1'
104+
args:
105+
- ${{ inputs.error }}
106+
```
107+
108+
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.**
109+
110+
## How it all works
111+
112+
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.
113+
114+
First, we use the [Docker container action](https://docs.github.com/en/actions/creating-actions/creating-a-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).
115+
116+
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.
117+
118+
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.
119+
120+
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:
121+
122+
```yaml
123+
env:
124+
REGISTRY: ghcr.io
125+
IMAGE_NAME: ${{ github.repository }}
126+
127+
jobs:
128+
build:
129+
steps:
130+
# other steps omitted
131+
- name: Build and push Docker image
132+
id: build-and-push
133+
uses: docker/build-push-action@v4
134+
with:
135+
context: .
136+
push: ${{ github.event_name != 'pull_request' }}
137+
tags: ${{ steps.meta.outputs.tags }}
138+
labels: ${{ steps.meta.outputs.labels }}
139+
cache-from: type=gha
140+
cache-to: type=gha,mode=max
141+
```
142+
143+
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!
144+
145+
```bash
146+
FROM rust:1.67 as build
147+
148+
# create a new empty shell project
149+
RUN USER=root cargo new --bin sample-rust-action
150+
WORKDIR /sample-rust-action
151+
152+
# copy over your manifests
153+
COPY ./Cargo.lock ./Cargo.lock
154+
COPY ./Cargo.toml ./Cargo.toml
155+
156+
# this build step will cache your dependencies
157+
RUN cargo build --release
158+
RUN rm src/*.rs
159+
160+
# copy your source tree
161+
COPY ./src ./src
162+
163+
# build for release
164+
RUN rm ./target/release/deps/sample_rust_action*
165+
RUN cargo build --release
166+
167+
# our final base
168+
FROM gcr.io/distroless/cc AS runtime
169+
170+
# copy the build artifact from the build stage
171+
COPY --from=build /sample-rust-action/target/release/sample-rust-action .
172+
173+
# set the startup command to run your binary
174+
ENTRYPOINT ["/sample-rust-action"]
175+
```
176+
177+
Going through this line by line, we:
178+
179+
1. Start with the official `rust` image for building—some slimmer images probably work, but this gives us maximum flexibility for template users.
180+
181+
2. 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](https://github.com/LukeMathWalker/cargo-chef).
182+
183+
3. The next few steps build just enough of our code to get dependencies to cache. Note that modifying `Cargo.lock` or `Cargo.toml` will bust the cache; this is partially why `cargo-chef` may be a better option.
184+
185+
4. The next couple of steps (starting with `COPY ./src ./src` and going through `RUN cargo build --release`) will build our finished binary
186+
187+
5. 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.
188+
189+
6. We pop our binary over into the fresh `cc` image, and set up the entry point (note that `CMD` doesn't work here; you have to use `ENTRYPOINT`). That's the whole image!
190+
191+
192+
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:
193+
194+
```yaml
195+
name: Test consuming this action
196+
on:
197+
pull_request:
198+
branches: [v1]
199+
workflow_run:
200+
workflows: ["Docker Publish"]
201+
branches: [v1]
202+
types:
203+
- completed
204+
205+
jobs:
206+
test_success:
207+
runs-on: ubuntu-latest
208+
steps:
209+
- uses: actions/checkout@v3
210+
- uses: ./
211+
212+
test_error:
213+
runs-on: ubuntu-latest
214+
steps:
215+
- uses: actions/checkout@v3
216+
- id: test
217+
continue-on-error: true
218+
uses: ./
219+
with:
220+
error: "This is an error"
221+
- name: Verify failure
222+
if: steps.test.outputs.error != ''
223+
run: echo "Failed as expected"
224+
- name: Unexpected success
225+
if: steps.test.outputs.error == ''
226+
run: echo "Succeeded unexpectedly" && exit 1
227+
```
228+
229+
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!
230+
231+
## Conclusion
232+
233+
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](https://github.com/dbanty/rust-github-action-template/issues). 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!

content/blog/stop-writing-dry-code/index.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ date = "2022-04-05"
44

55
[extra]
66
featured_image = "cover.jpeg"
7-
featured_image_extended = true
87
featured_image_alt = "A large vanilla cake with whipped vanilla buttercream icing, against a purple background, is cut open and sand is spilling forth creating a vast desert beneath. A lone cactus stands sentinel. The top right reads \"Stop Writing DRY Code.\""
98
discussion = 199
109

static/main.css

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tailwind.config.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ module.exports = {
2626
"hover:bg-indigo-300",
2727
"dark:bg-indigo-700",
2828
"dark:hover:bg-indigo-600",
29+
// Purple tags
30+
"bg-purple-200",
31+
"hover:bg-purple-300",
32+
"dark:bg-purple-700",
33+
"dark:hover:bg-purple-600",
2934
],
3035
theme: {
3136
extend: {

templates/macros/page.html

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
{% set image_path = image_path ~ "/" ~ page.extra.featured_image %}
77
{% if preview %}
88
{% set width=800 %}
9-
{% set height=500 %}
9+
{% set height=533 %}
1010
{% else %}
1111
{% set width=1200 %}
1212
{% set height=800 %}
@@ -34,6 +34,8 @@
3434
teal
3535
{%- elif name == "serverless" -%}
3636
indigo
37+
{%- elif name == "developer-experience" -%}
38+
purple
3739
{%- else -%}
3840
zinc
3941
{%- endif -%}

0 commit comments

Comments
 (0)