I'm writing a library that makes it easier to write GitHub Action in rust. It currently provides two functionalities:
- A
macro_rules!
macro that returns output from the Rust code to the action runner. - A attribute-like proc macro to put on your main function that allows you to use the
?
operator directly in main. Errors are returned to the action runner in theerror
output.
For my workspace setup I followed the instructions in this post. You can find the repo here, but I'll include the relevant files without documentation here as well.
Cargo.toml
[package]
name = "gha_main"
version = "0.0.2"
edition = "2021"
[dependencies]
anyhow = "1.0.71"
gha_main-proc_macro = { path = "src/proc_macro", version = "0.0.2" }
lazy_static = "1.4.0"
uuid = { version = "1.3.3", features = ["v4"] }
[workspace]
members = ["src/proc_macro"]
src/lib.rs
pub use gha_main_proc_macro::gha_main;
#[doc(hidden)]
pub extern crate anyhow;
#[doc(hidden)]
pub extern crate lazy_static;
#[doc(hidden)]
pub extern crate uuid;
pub type GitHubActionResult = anyhow::Result<()>;
#[macro_export]
macro_rules! gha_output {
($value:ident) => {
let key = stringify!($value);
let value = $value.to_string();
$crate::write_output(key, value, &mut OUTPUT_FILE.lock().unwrap());
};
}
pub fn write_output(key: &str, value: String, output_file: &mut File) {
let delimiter = uuid::Uuid::new_v4();
std::writeln!(
output_file,
"{}<<{}\n{}\n{}",
key,
delimiter,
value,
delimiter,
)
.expect("Failed to write output");
}
src/proc_macro/Cargo.toml
[package]
name = "gha_main-proc_macro"
version = "0.0.2"
edition = "2021"
[dependencies]
quote = "1.0.27"
syn = { version = "2.0.16", features = ["full"] }
proc-macro2 = "1.0.58"
proc-macro-error = "1.0.4"
[lib]
proc-macro = true
path = "mod.rs"
src/proc_macro/mod.rs
use proc_macro::TokenStream;
use proc_macro2::Ident;
use proc_macro_error::{abort, proc_macro_error};
use quote::quote;
use syn::{parse_macro_input, ItemFn};
#[proc_macro_attribute]
#[proc_macro_error]
pub fn gha_main(_args: TokenStream, item: TokenStream) -> TokenStream {
let input_fn = parse_macro_input!(item as ItemFn);
let ident = &input_fn.sig.ident;
verify_main(ident);
TokenStream::from(quote! {
use std::fs::File;
use std::fs::OpenOptions;
use std::io::Write;
use std::sync::Mutex;
use gha_main::anyhow::{bail, Result};
use gha_main::uuid::Uuid;
use gha_main::lazy_static::lazy_static;
lazy_static! {
static ref OUTPUT: String =
std::env::var("GITHUB_OUTPUT").unwrap_or("github_output".to_string());
static ref OUTPUT_FILE: Mutex<File> = Mutex::new(OpenOptions::new()
.create(true)
.append(true)
.open(&*OUTPUT)
.expect("Failed to create or open output file"));
}
fn main() -> Result<()> {
#input_fn
// If an error was propagated from the inner function, write it to the output file
if let Err(error) = #ident() {
gha_output!(error);
bail!("Action failed with error: {}", error);
}
Ok(())
}
})
}
fn verify_main(ident: &Ident) {
if ident != "main" {
abort!(ident, "function must be called `main`");
}
}
The attribute macro wraps the user's main function in a new main that handles errors that are returned from the inner main. It also imports the types and traits needed for the macro_rules!
macro to work and initializes lazy statics to write the outputs and errors to the correct file.
This all works but a couple of things are not ideal:
- I need to add imports in the attribute macro output
TokenStream
for types reexported from the main library while the dependency is the other way around. This is a constant source of confusion. - Rust analyzer doesn't seem to import to code in src/lib.rs and it doesn't help with checking the code in the proc macro
TokenStream
. Overall this leads to pretty bad DX.
This is my first time writing Rust macros so maybe this is inherent to the task but it all feels very hacky. Is there a better way to organize this code?