wrkflw: Run GitHub Actions locally without pushing to find out

wrkflw: Run GitHub Actions locally without pushing to find out

wrkflw validates and runs GitHub Actions workflows locally. You point it at a .yml file, it parses the DAG, respects needs:, evaluates ${{ }} expressions, and shells out to Docker, Podman, or a sandboxed host process — no push required.

Why I starred it

The usual workflow for debugging CI is: edit YAML, push, wait two minutes, read the log, repeat. act exists, but it has its own quirks and expression support is uneven. wrkflw takes a more complete approach: it's built as a Cargo workspace with dedicated crates for parsing, expression evaluation, trigger filtering, matrix expansion, and secrets — each with a single responsibility.

What caught my attention was the --diff flag. Most local runners ignore on: paths: entirely. wrkflw actually parses the on: block, diffs against origin/HEAD (or a supplied range), and skips workflows whose path filters wouldn't match the changed files. That's the thing that makes it useful on real repos where you have a dozen workflows and only two of them care about src/.

How it works

The workspace has 16 crates. The execution path is clean: main.rs parses CLI args via clap, calls execute_workflow() in crates/executor/src/engine.rs, which hands off to dependency::resolve_dependencies() before doing anything else.

dependency.rs implements a straightforward topological sort — build an adjacency map from needs:, find nodes with no incoming edges, emit them as a parallel batch, remove their edges, repeat. The result is Vec<Vec<String>> where the outer vec is the execution order and each inner vec is jobs that can run concurrently.

// crates/executor/src/dependency.rs
pub fn resolve_dependencies(workflow: &WorkflowDefinition) -> Result<Vec<Vec<String>>, String> {
    // ...
    while !no_dependencies.is_empty() {
        let current_level: Vec<String> = no_dependencies.iter().cloned().collect();
        result.push(current_level);
        // peel the level, expose next batch
    }
    result
}

The expression evaluator in crates/executor/src/expression.rs is hand-rolled — 2,700 lines covering tokenization, parsing, and evaluation of the full ${{ }} grammar. It defines its own ExprValue enum with truthiness semantics that match GitHub's spec (0, "", false, and null are falsy; everything else is truthy). The Object variant uses HashMap<String, ExprValue> and coerces to JSON when used in a string context — exactly what GitHub does when you write ${{ toJSON(env) }}.

// crates/executor/src/expression.rs
pub enum ExprValue {
    String(String),
    Number(f64),
    Bool(bool),
    Null,
    Object(HashMap<String, ExprValue>),
}

impl ExprValue {
    pub fn is_truthy(&self) -> bool {
        match self {
            ExprValue::Bool(b) => *b,
            ExprValue::Number(n) => *n != 0.0 && !n.is_nan(),
            ExprValue::String(s) => !s.is_empty(),
            ExprValue::Null => false,
            ExprValue::Object(_) => true,
        }
    }
}

The trigger filter logic lives in crates/trigger-filter/src/eval.rs. A detail worth noting: GitHub's on: push: branches: [...] tags: [...] semantics are OR, not AND — a push event matches if either the branch filter or the tag filter passes. wrkflw had a bug where it checked them sequentially (AND), which caused it to silently skip branch pushes when the workflow also had a tags: filter. The fix is pinned by a test named push_with_branches_and_tags_is_or_not_and. That kind of specificity in a test name tells you the maintainer hit this in the wild.

The four runtime modes use a ContainerRuntime trait. Docker and Podman implement it via bollard. The secure-emulation mode (crates/runtime/src/secure_emulation.rs) skips containers entirely but wraps commands in a Sandbox with filesystem and network restrictions — the right call when you want to run untrusted workflow YAML on your host without spinning up Docker.

Using it

cargo install wrkflw
# or
brew install wrkflw

# validate everything in .github/workflows
wrkflw validate

# run a specific workflow
wrkflw run .github/workflows/ci.yml

# skip workflows that wouldn't fire for this change set
wrkflw run --event push --diff .github/workflows/ci.yml

# run without Docker
wrkflw run --runtime secure-emulation .github/workflows/ci.yml

# TUI — shows a DAG tab and live logs
wrkflw tui

The TUI is ratatui-based, with seven tabs: Workflows, Execution, DAG, Logs, Trigger, Secrets, Help. The DAG tab visualizes the dependency graph, which is the kind of thing that earns a tool a second look.

Rough edges

Service containers (services:) are parsed but never started in any runtime mode. The code logs "service containers are not implemented" regardless of which --runtime you pick — so if your workflow spins up Postgres, that step quietly does nothing. The README now says this clearly, but earlier versions didn't.

Windows and macOS runs-on: targets are silently remapped — macos-* becomes a Linux image with Rust, windows-* becomes a Windows container that won't run on a Linux host. ${{ runner.os }} reports the host OS, not the runs-on value. This is expected behavior for a local runner but it's the kind of silent mismatch that bites you if you're testing platform-conditional steps.

The expression evaluator is custom, which means edge cases in the ${{ }} grammar could diverge from GitHub's actual implementation. There's no fuzz test against the real GitHub expression engine, so subtle differences in string coercion or operator precedence are possible.

Private repos in remote uses: aren't supported — the clone is unauthenticated. If your org uses shared reusable workflows from private repos, this won't work.

Bottom line

If you push to find out whether your CI works, wrkflw is worth installing. The trigger-aware filtering and proper needs: resolution put it ahead of naively just running each job script. The 0.8.0 release from a few weeks ago cleaned up most of the documentation rot — the README now accurately describes what works and what doesn't.

bahdotsh/wrkflw on GitHub
bahdotsh/wrkflw