actions-rs 0.1.0

Zero-dependency toolkit for writing GitHub Actions in Rust: annotations, workflow commands, environment files, typed inputs and job summaries.
Documentation
  • Coverage
  • 100%
    213 out of 213 items documented130 out of 130 items with examples
  • Size
  • Source code size: 151.7 kB This is the summed size of all the files inside the crates.io package for this release.
  • Documentation size: 2.52 MB This is the summed size of all files generated by rustdoc for all configured targets
  • Ø build duration
  • this release: 10s Average build duration of successful builds.
  • all releases: 10s Average build duration of successful builds in releases after 2024-10-23.
  • Links
  • Homepage
  • kjanat/actions-rs
    0 0 0
  • crates.io
  • Dependencies
  • Versions
  • Owners
  • kjanat

actions-rs

A zero-dependency, #![forbid(unsafe_code)] Rust toolkit for talking to the GitHub Actions runner from a binary/Docker action or any CI step.
It speaks the workflow-command and environment-file protocols — an independent, unofficial Rust port of @actions/core (faithful API and semantics, with deliberate safety-first departures).
Not affiliated with or endorsed by GitHub or the @actions/toolkit project.

What it's for (and the one thing it does best)

The flagship is a precise annotation builder: notice/warning/error with file + line/column ranges and a title, using the exact data-vs-property percent-encoding @actions/core uses (%, \r, \n everywhere; :, , additionally in properties). Getting that encoding wrong silently corrupts annotations in the GitHub UI — this crate gets it right and unit-tests the encoding tables directly.

use actions_rs::{Annotation, AnnotationSpan};

Annotation::new()
    .file("src/parser.rs")
    .span(AnnotationSpan::Column {
        line: 42,
        start: 5,
        end: Some(7),
    })
    .title("clippy::needless_clone")
    .warning("redundant clone of `cfg`");
// emits this single line to stdout:
// ::warning title=clippy%3A%3Aneedless_clone,file=src/parser.rs,line=42,col=5,endColumn=7::redundant clone of `cfg`

Around that it provides the rest of the toolkit surface:

  • logging + panic-safe group, mask/set_secret, RAII stop_commands, echo, set_failed/fail_now;
  • env files (GITHUB_ENV/OUTPUT/STATE/PATH) with a collision-safe, std-only random heredoc delimiter (the CVE-2020-15228 injection class) and deprecated stdout fallback only for output/state; reserved-name guard; safe same-process overlay helpers for env/PATH;
  • typed inputs: strict YAML 1.2 bool_input, multiline_input, input_as::<T: FromStr>, mask_input;
  • a fluent Summary builder (1 MiB guarded, escaped by default, raw HTML opt-in via SummaryText::html);
  • runtime detection + a typed Context;
  • crate-root warning! / group! / … format!-style macros.

Why

  • Zero dependencies. Std only. Nothing to audit, fast to build.
  • Correct by construction. Percent-encoding for command data vs. properties, collision-checked random heredoc delimiters for multiline environment-file values (the CVE-2020-15228 class of bug), strict YAML 1.2 boolean inputs.
  • Honest errors. Filesystem/parse operations return Result; pure stdout commands are infallible — no fake error channel.
  • Modern + compatible. Uses GITHUB_ENV/GITHUB_OUTPUT/… directly; only set_output / save_state keep deprecated stdout fallbacks where GitHub still supports them.

Differences from @actions/core

Faithful to the protocol, but deliberately divergent where Node semantics would be unsafe or dishonest in Rust. None of these change the bytes the runner sees; they change what the calling process can rely on:

  1. No std::env mutation. @actions/core's exportVariable / addPath also write process.env so the current process sees the change. That write is unsafe in Rust edition 2024 (it is unsound when another thread may be running) and this crate is #![forbid(unsafe_code)], so export_var / add_path only write the env file. For same-process visibility use the safe overlay: overlay_var, overlay_path, or apply_overlay(&mut Command) for child processes.
  2. No retired-command fallback for env/PATH. set_output / save_state keep the merely-deprecated ::set-output:: / ::save-state:: stdout fallback. export_var / add_path do not fall back to ::set-env:: / ::add-path:: — GitHub disabled those (CVE-2020-15228, changelog) — so they return Error::UnavailableFileCommand off-runner instead of emitting a command the runner ignores.
  3. Injection guard. A \r/\n in an output/state/env key or a PATH entry returns Error::InvalidName rather than silently injecting extra env-file entries (the CVE-2020-15228 class). The heredoc delimiter is collision-checked → Error::DelimiterCollision.
  4. Honest errors. Filesystem/parse operations return Result; pure stdout commands stay infallible. No swallowed errors, no fake channel.
  5. Summary escaped by default. Summary HTML-escapes text and attributes; raw HTML is explicit opt-in via SummaryText::html. @actions/core concatenates raw HTML.
  6. Deferred failure, not process.exit. set_failed sets a flag instead of exiting; is_failed() / exit_code() -> std::process::ExitCode let main decide when to exit, so destructors and cleanup still run.

Honest comparison with the alternatives

This is not the only crate in this space. Pick the right tool:

Crate Deps Annotation builder (file+range) Env files Typed inputs Summary API client / derive Notes
actions-rs (this) zero, forbid(unsafe) yes, exact encoding, range yes yes (FromStr) yes no annotation-first; smallest, strictest
gha zero not as a builder yes basic yes no closest overlap; mature, macro-style
ghactions octocrab/serde/derive (opt.) no yes yes (derive) no yes (#[derive(Actions)], octocrab) biggest, batteries-included
github-actions uuid (opt.) no yes yes yes no similar, tiny
actions-core uuid 0.8 no (loose file/line/col) no[^ac] basic no no v0.0.2 (2020-04-01)

[^ac]: From its published core.rs (latest release 0.0.2, 2020-04-01, per crates.io): set_env / add_path emit the stdout ::set-env:: / ::add-path:: workflow commands.
GitHub announced the disabling of that command pair in October 2020 (changelog, CVE-2020-15228) — distinct from the ::set-output:: / ::save-state:: pair, which was later only deprecated.

When to use this one: you want zero dependencies and #![forbid(unsafe_code)] in a CI/security context, and you care about correct, ranged annotations.
When not to: you want a GitHub API client, action.yml generation, or derive-driven input structs — use ghactions.
If you just want zero-dep logging/env helpers and don't need the annotation builder, gha is mature and fine.

Not provided here: REST/GraphQL client, OIDC, action.yml derive/codegen, tool cache, glob, command exec. These are deliberately a different crate's job — pair this with one that covers them.

Quick start

The asserts below are real: this block is compiled and run as a doctest (see tests/ and the cfg(doctest) README harness), so the documented behaviour is verified on every cargo test.

use actions_rs::{Annotation, AnnotationSpan, Cell, Summary, SummaryText, log, output};

fn main() -> actions_rs::Result<()> {
    // Typed, validated input. `INPUT_VERBOSE` is unset here, so the default
    // is used. (`bool_input` is strict YAML 1.2: only true/True/TRUE/...)
    let verbose = actions_rs::input::bool_input("verbose").unwrap_or(false);
    assert!(!verbose);

    // Register a secret; the runner redacts it from all later log output.
    log::set_secret("hunter2");

    // Annotation with a precise source span -> one workflow command on stdout:
    // ::warning title=lint,file=src/main.rs,line=42,col=5,endColumn=9::unused import
    Annotation::new()
        .file("src/main.rs")
        .span(AnnotationSpan::Column {
            line: 42,
            start: 5,
            end: Some(9),
        })
        .title("lint")
        .warning("unused import");

    // `format!`-style macro, exported at the crate root.
    actions_rs::warning!("verbose = {verbose}");

    // Panic-safe group: emits ::group::/::endgroup:: even if the body
    // panics, and forwards the body's value.
    let answer = actions_rs::group!("build", { 6 * 7 });
    assert_eq!(answer, 42);

    // Build a job summary. Text is HTML-escaped by default; raw HTML is an
    // explicit opt-in via `SummaryText::html`. `stringify` renders it in
    // memory (no runner needed), so the structure is verifiable right here.
    let mut summary = Summary::new();
    summary
        .heading("Result", 2)
        .details(
            "note: <b> stays literal",
            SummaryText::html("<strong>raw opt-in</strong>"),
        )
        .table([vec![Cell::header("answer"), Cell::new(answer.to_string())]]);
    let rendered = summary.stringify();
    assert!(rendered.contains("<h2>Result</h2>"));
    assert!(rendered.contains("note: &lt;b&gt; stays literal")); // escaped by default
    assert!(rendered.contains("<strong>raw opt-in</strong>")); // explicit html(): not escaped
    assert!(rendered.contains(r#"<td colspan="1" rowspan="1">42</td>"#));

    // Runner-only side effects. `GITHUB_OUTPUT`/`GITHUB_ENV` only exist on a
    // runner, and `export_var`/`add_path` have NO stdout fallback (GitHub
    // disabled `::set-env::`/`::add-path::`), so they error off-runner by
    // design -- gate them behind the runtime check.
    if actions_rs::env::is_github_actions() {
        log::info("running inside GitHub Actions");
        output::set_output("answer", answer)?;

        // `export_var` writes GITHUB_ENV for *later* steps. It does not (and
        // cannot safely) mutate this process's env -- `std::env::set_var` is
        // `unsafe` in edition 2024. To observe it same-process, read the safe
        // overlay instead of `std::env::var`.
        output::export_var("BUILD_OK", true)?;
        assert_eq!(output::overlay_var("BUILD_OK").as_deref(), Some("true"));

        summary.write()?;
    }
    Ok(())
}

Module map

Module Purpose
log annotations, group, mask/set_secret, stop_commands, echo, set_failed/fail_now, exit_code/is_failed
annotation Annotation builder (AnnotationSpan, file + line/column range + title)
input input, input_required, bool_input, multiline_input, multiline_input_with, input_as::<T>, mask_input
output set_output, save_state, get_state, export_var, add_path, overlay_var, overlay_path, apply_overlay
summary fluent Summary builder (SummaryText::html for raw HTML, 1 MiB guarded)
env is_github_actions/is_ci/is_debug, RunnerOs, RunnerArch, Context, vars constants
command low-level WorkflowCommand for anything not covered above

See examples/demo.rs for a runnable tour.

Compatibility

  • Rust 1.95+ (edition 2024), #![forbid(unsafe_code)], zero dependencies.
  • Dual-licensed MIT OR Apache-2.0.
  • Unrelated to the archived actions-rs GitHub org (setup-rust actions); this is an independent crate of the same (previously unregistered) name.