Files
wrkflw/crates/executor
Gokul 7b2a27a532 refactor(executor): separate user env from runner env in ExpressionContext (#102)
* refactor(evaluator): add user_env field to ExpressionContext

Prior commits landed bare-context support for toJSON(steps),
toJSON(needs), toJSON(github), toJSON(secrets), and toJSON(matrix).
toJSON(env) is next — and it's the ugly one. The existing
implementation filters env_context through an is_user_env_var()
heuristic: "if the key doesn't start with GITHUB_/RUNNER_/INPUT_/
WRKFLW_ and isn't CI or MATRIX_CONTEXT, it must be user-declared."

It turns out that's wrong in both directions. A user who writes
`env: { GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} }` in their YAML —
which is the single most common pattern on planet Earth — gets their
token silently dropped from toJSON(env). Meanwhile any new runner
var that isn't on the exclusion list leaks in. Key-level guessing
can't separate the two populations after they've been merged into
one flat map. The fix has to happen at the seam.

So: add `user_env: &'a HashMap<String, String>` to ExpressionContext
alongside env_context. env_context stays as-is (it's the union, used
for dotted `env.FOO` and `github.FOO` lookups); user_env carries
only what the user declared, and that's what toJSON(env) reads.

This commit is the mechanical half. The field is wired through every
construction site, but at each production site in engine.rs we build
user_env inline by running the old heuristic against env_context —
exactly the behavior we had before, just relocated. Not pretty, but
it keeps the tree green and all 399 tests passing while the plumbing
lands. Commit 2 replaces these inline filters with a user_env that's
tracked properly through the workflow/job/step env merges and the
$GITHUB_ENV writes, and deletes is_user_env_var for good.

The test helpers (empty_ctx, make_ctx, etc.) dual-populate env_context
and user_env from the single env argument, so most tests didn't
need touching. Three toJSON(env) tests did — they were stuffing
runner vars into the same map as user vars and asserting the
heuristic filtered them, which is a thing that can't be expressed
anymore. Those now construct ExpressionContext directly with
distinct populations. While at it, the test that was literally
called tojson_env_excludes_user_var_with_internal_prefix — which
asserted the bug as if it were a feature — is inverted and renamed.
Please don't write tests that ratify bugs.

StepExecutionContext::expr_context() and expr_context_with_env()
grew a user_env_buf: &mut HashMap parameter because the returned
ExpressionContext has to borrow from somewhere, and self is
already borrowing job_env. Callers pass a local buffer. Ugly but
localized — and this too goes away in Commit 2 when the struct
carries its own user_env field.

* refactor(executor): track user_env through layered env merges

Commit 1 added the user_env field to ExpressionContext but populated
it at each construction site by re-running the same is_user_env_var()
heuristic against env_context. That was honest plumbing — it said
"here's where the field goes" without actually changing any behavior.
Now we replace the heuristic with the thing it was always pretending
to be.

The real shape is: env_context is still the union used for dotted
lookups (env.FOO, github.X). Alongside it we now carry user_env as a
parallel HashMap that is strictly what the user declared — nothing
more, nothing less. Thread it through JobExecutionContext,
MatrixExecutionContext, and StepExecutionContext, populated at the
four places where user env actually enters the system:

  1. Workflow-level: seeded empty, workflow.env resolved values
     get mirrored in (with the same or_insert precedence as
     env_context, so workflow.env doesn't shadow runner vars).
  2. Job-level: clone workflow user_env, layer container.env and
     job.env on top. Runner-internal GITHUB_JOB (via
     add_job_context) stays in env_context only.
  3. Matrix jobs: same as job-level but skip the MATRIX_*
     vars — they're runner-internal, they belong to the matrix
     context, not to env.
  4. Step-level: clone job user_env, layer step.env on top after
     expression resolution.

And the one that mattered most: GITHUB_ENV writes from steps.
apply_step_environment_updates now mirrors env-file writes into
user_env too, because a step running 'echo FOO=bar >> GITHUB_ENV'
is user-declaring FOO by definition — otherwise a step that writes
its own FOO wouldn't see it in the next step's toJSON(env).
GITHUB_PATH updates stay out of user_env because PATH is not a
user-declared env var. There's a test for this.

With real tracking in place, is_user_env_var() is gone, along with
its environment.rs test that did nothing but rubber-stamp the
heuristic's list of prefixes. StepExecutionContext::expr_context()
and expr_context_with_env() lose the ugly user_env_buf: &mut
HashMap parameter that Commit 1 used as a bridge — they read
self.job_user_env directly now.

Two non-obvious scope decisions, both flagged in the code:

  - Reusable workflows (run_called_workflow) get an empty
    user_env on entry. Parent workflow.env doesn't leak across
    reusable-workflow boundaries, and the called workflow's own
    workflow.env isn't merged into child_env at all (that's a
    pre-existing gap, not mine to fix here). Job-level env merges
    still populate user_env correctly downstream.
  - GitLab pipelines don't carry a workflow-level env:, so the
    gitlab-path callsite also starts with empty user_env.

398 tests pass, clippy clean, fmt clean. The PR that ships this
fixes the original bug: a user who writes their GITHUB_TOKEN via
workflow/job/step env: will now actually see it in toJSON(env).
Which, let's be honest, they should have been able to see all along.
2026-04-18 23:21:40 +05:30
..

wrkflw-executor

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

  • Job graph execution with needs ordering and parallel independent jobs
  • Docker/Podman container steps and emulation mode
  • Run individual jobs via target_job / --job flag
  • GitHub Actions environment file support (GITHUB_OUTPUT, GITHUB_ENV, GITHUB_PATH, GITHUB_STEP_SUMMARY) with read-back
  • Docker-based action resolution (container, JavaScript, composite, local)
  • Job-level container: directive support
  • 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: Some("build".to_string()), // run a single job
};

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.