Skip to content

Reusable Workflow Design

Decision

A step invokes another workflow when its uses value is a relative path beginning ./ or ../. This is the GitHub Actions convention for local reusable workflows and keeps workflow calls disjoint from action provider names without a new step key or schema change.

Grammar

uses value Meaning
causaliq-discovery Action provider resolved by ActionRegistry
./modules/learn.yml Workflow call, relative to the declaring file
../shared/base.yaml Workflow call in a parent directory

The target must use a .yml or .yaml suffix. with supplies parameters for the called workflow.

Alternatives

Option Assessment
call: ./x.yml Explicit, but adds a third step kind plus schema, pre-validation and dispatch changes
uses: causaliq-workflow with with.workflow: Collides with the built-in provider and reserves a parameter name
uses: ./x.yml Chosen: standard CI convention, no schema change, reuses uses and with

Call Stack

WorkflowFrame records the canonical path and directory of each active workflow. execute_workflow seeds the stack from the parsed workflow path, and the source_path argument overrides it. Each call extends the stack.

  • Relative targets resolve against the innermost frame's source_dir.
  • A target already on the stack is a recursive call and is rejected.
  • os.path.normcase makes path comparison case-insensitive on Windows.
  • Two sibling calls to the same child are allowed: the stack is a chain, not a visited set.

When no source path is known, relative targets resolve against the working directory and the root workflow cannot be detected as a cycle.

Execution Model

A call step delegates to _execute_jobs with the child workflow, the caller's mode, the bound parameters and the extended stack. The child therefore expands its own matrix and writes its own caches.

Parameter binding

with values are resolved in the caller's context and then bound by _bind_parameters (workflow/matrix.py):

  • a name that matches a child matrix dimension replaces that dimension's values (a scalar becomes [value], a list is used as given);
  • every other name becomes a template variable of the child.

A value that is exactly one {{name}} token is bound natively, so an integer matrix override stays an integer. Matrix overrides are removed from the variable map, so a list-valued override cannot leak into {{name}} substitution. Binding is pure: the parsed child is never mutated, which matters when the same parent job runs a call twice.

Parameters never add a matrix dimension, so they are not part of a cache key. Conservative execution therefore still skips an entry when only a parameter value changed; --mode=force re-runs it. Authors who need a parameter to distinguish cache entries declare it as a matrix dimension (for example alpha: [null]) and let the caller override it.

Top-level keys other than matrix and steps are declared parameters with defaults. A default is a scalar, a list of scalars or null. A list default is a single value: _resolve_template_variables returns the list itself for a whole-value {{name}} token, and _bind_parameters uses it as the override values when the called workflow declares the same name as a matrix dimension. That is how a caller-level list fans out inside a callee's matrix while the call step itself still runs once.

A null default declares a parameter with no value: binding it to a step template yields None, which the action boundary drops, so the parameter reaches the action as omitted. A placeholder that is not declared is required, so a caller or the CLI must supply it; _validate_template_variables (and _missing_variable_errors for dict-based execution) reports it when it is missing. Unknown names are reported by _call_parameter_errors with the accepted names. Ineffective declarations — an unused parameter, a null-default parameter used as part of a value, or a list-default parameter used anywhere other than as a whole step parameter value — are reported by _parameter_declaration_errors. A step's name counts as a use, because the executor expands its {{placeholder}} tokens for each job.

Validation resolves a call step's with values in the caller's context before binding them to the called workflow, so a forwarded matrix value or declared parameter reaches the child as a value. Without that step the child would receive the literal {{placeholder}} text and reject it as an invalid action parameter. A call whose parameters vary across the caller's matrix is validated once, for the caller's first job.

Run-once rule

_runs_once(step, matrix) treats a call step as single-run unless its with values reference a matrix variable of the calling workflow. A constant call would do identical work on every job, so it runs once and is skipped later; a call whose parameters vary resolves to different parameters per combination, so it runs once per combination.

Validation

_load_child_workflow loads and validates each called workflow: validate_workflow, _validate_template_variables (with the caller's parameter names available), and _validate_workflow_actions. The validation pass then binds the caller's parameters and recurses through _validate_all_entries, so execute_workflow(mode="validate") covers the whole tree.

Call steps are excluded from action-provider, action-pattern and filter validation. with.action and with.output are rejected because the executor owns those keys.

Reporting

The call step records a summary:

{
    "status": "success",       # error / would_execute / would_skip
    "workflow": "<canonical path>",
    "jobs": 2,
    "parameters": {"variant": "two"},   # omitted when none
    "steps": [
        {"name": "Child record", "status": "success"},
        {"name": "Child record", "status": "success"},
    ],
}

Called step failures are folded into status: error plus an errors list, so the CLI report treats a failing call like an ordinary failing step.

Each nested step summary also carries the fields needed to report the run consistently at any depth: a nested call keeps its workflow marker and its own steps, and a job with matrix values contributes matrix_values. That lets the final summary count leaf steps at every level and name the failing matrix combination in the error list.

The CLI report counts a call step's nested step results in place of the call itself, so a parent that calls one twelve-step workflow reports twelve steps, not one.

Call steps are announced through the step logger (CALLING) before the called workflow's own step messages, and a COMPLETED message carrying the call's cumulative step counts is emitted when the child returns. Every event carries its call depth, so the CLI indents deeper levels and --log-depth can stop reporting below a chosen level.

Deferred

  • Parameter values invalidating or keying cache entries.
  • Step output chaining from a called workflow to its caller.
  • Memoising loaded child workflows across parent jobs.