Files
wrkflw/crates/executor
Gokul 6aa0a380f9 feat(executor): add GitHub Actions environment file read-back (#83)
* feat(executor): add GitHub Actions environment file read-back

After each step executes, read back GITHUB_OUTPUT, GITHUB_ENV,
GITHUB_PATH, and GITHUB_STEP_SUMMARY files to enable inter-step
data flow — the single highest-value GHA emulation improvement.

- Add github_env_files module with parser for GHA key-value format
  (simple key=value and multiline heredoc key<<DELIMITER)
- Read GITHUB_OUTPUT after each step, store outputs keyed by step ID
- Merge GITHUB_ENV entries into job env for subsequent steps
- Prepend GITHUB_PATH entries to PATH for subsequent steps
- Add ${{ steps.<id>.outputs.<key> }} expression substitution
- Add ${{ env.<name> }} expression substitution
- Extend StepResult with outputs field for parsed step outputs
- Restructure execute_job() loop to allow job_env mutation between
  steps using deadline-based timeout instead of async block wrapper

* fix(executor): fix env file read-back bugs and deduplicate post-step logic

The previous commit added environment file read-back but had a few
issues that would bite in real workflows.

First, the heredoc parser was checking for `<<` before checking for
`=`, which means a value like `url=https://example.com/path<<EOF`
would be misinterpreted as a heredoc start. The fix is obvious: the
key before `<<` must actually be a valid identifier, not just "any
non-empty string". Add is_valid_identifier() to enforce that.

Second, GITHUB_ENV and GITHUB_PATH files were never truncated between
steps. Since we read and merge their *entire* contents after each
step, step 2 would re-process step 1's entries, causing duplicate
PATH entries to accumulate O(n²) with each step. We already merge
into job_env which is the source of truth — just truncate all three
files after read-back.

Third, the ~25 lines of post-step read-back logic were copy-pasted
between execute_job and execute_matrix_job. Extract into
apply_step_environment_updates() so there's exactly one place to
maintain this.

While at it, add tests for the heredoc ambiguity, unterminated
heredocs, the apply helper, and — most importantly — a multi-step
test that verifies PATH entries don't duplicate across steps.

* fix(executor): remove dead StepResult.outputs field and fix timeout regression

The previous commit added an `outputs: HashMap<String, String>` field
to StepResult, but *never actually populated it* — every single
construction site (all 20 of them) just sets it to HashMap::new().
The real step outputs live in step_outputs_map inside the job loop,
completely bypassing this field.

A dead field that looks like it should contain data is worse than no
field at all. It's a trap for the next person who tries to read step
outputs through the obvious API. Remove it.

While at it, fix two issues in the execute_job timeout refactor:

The pre-loop `remaining.is_zero()` check was redundant — passing
Duration::ZERO to tokio::time::timeout already does the right thing.
Having both just produced duplicate log messages.

More importantly, the old timeout handler returned the timeout
reason in JobResult.logs. The refactored version just broke out of
the loop and lost that context entirely. Preserve it.

Also document why clear_step_files intentionally skips
GITHUB_STEP_SUMMARY (it's cumulative across steps in real GHA).

* fix(runtime): fix emulation command execution mangling bash -c scripts

It turns out the emulation runtime was joining the entire command
array into a single string and passing it to `sh -c`. This means
`bash -c <multiline-script>` became `sh -c "bash -c word1 word2
..."`, where sh happily re-split the script into separate words and
bash's -c only got the first one.

The result: every `run:` step *appeared* to succeed (exit 0) but
the actual script body never executed properly. Redirects like
`>> $GITHUB_OUTPUT` were captured by the outer sh instead of the
inner bash, so environment file write-back silently wrote garbage.

Fix this by executing known interpreters (bash, sh, python, pwsh)
directly via Command::new(cmd[0]).args(&cmd[1..]), preserving the
script as a single argument. Unknown commands still fall back to
sh -c for shell builtin and pipeline support. This also collapses
the three separate code paths (simple commands, cargo, fallback)
into one unified path, which is just less code to get wrong.

While at it, fix a second bug in apply_step_environment_updates
where GITHUB_PATH entries would clobber the entire PATH with just
the new entries when job_env had no PATH key yet. Fall back to the
system PATH instead of empty string so subsequent steps can still
find bash. Kind of important.

* fix(runtime): handle absolute interpreter paths and restore CI_PROJECT_DIR substitution

The previous emulation refactor matched command[0] against bare names
like "bash" and "cargo", which means /usr/bin/bash or /bin/sh would
fall through to the sh -c wrapper — silently reintroducing the exact
argument-mangling bug that refactor was supposed to fix.

Extract the basename via Path::file_name() before matching. This is
the obvious thing to do and I'm mildly annoyed it wasn't done the
first time around.

While at it, restore the ${CI_PROJECT_DIR} interpolation in env vars
that got quietly dropped during the three-code-path consolidation.
The old code only special-cased CARGO_HOME, but the real issue is
broader: *any* env var value containing ${CI_PROJECT_DIR} needs
interpolation. The new version handles all of them uniformly.

Also add a comment explaining why composite actions get an empty
step_outputs map — it's intentional (matches GHA scoping rules),
not a TODO someone should "fix" later.

* fix(executor): add timeout enforcement to matrix jobs and clean up review nits

execute_matrix_job had *no* timeout enforcement whatsoever. The
regular execute_job path got the per-step deadline treatment in
5792154, but matrix jobs were left behind with a bare .await? that
would happily run until the heat death of the universe.

Apply the same sanitize_timeout_minutes / job_deadline /
saturating_duration_since pattern so matrix jobs get identical
timeout behavior.

While at it, consolidate the duplicate std::env::current_dir()
calls in emulation.rs into a single variable, and clarify the
is_valid_identifier doc comment to explain it validates GHA env
file keys (not step IDs, which allow hyphens).
2026-04-02 23:41:36 +05:30
..

wrkflw-executor

The execution engine that runs GitHub Actions workflows locally (Docker, Podman, or emulation).

  • Features:
    • Job graph execution with needs ordering and parallelism
    • Docker/Podman container steps and emulation mode
    • Basic environment/context wiring compatible with Actions
  • Used by: wrkflw CLI and TUI

API sketch

use wrkflw_executor::{execute_workflow, ExecutionConfig, RuntimeType};

let cfg = ExecutionConfig {
    runtime: RuntimeType::Docker,
    verbose: true,
    preserve_containers_on_failure: false,
    target_job: None,
};

// Path to a workflow YAML
let workflow_path = std::path::Path::new(".github/workflows/ci.yml");

let result = execute_workflow(workflow_path, cfg).await?;
println!("workflow status: {:?}", result.summary_status);

Prefer using the wrkflw binary for a complete UX across validation, execution, and logs.