Skip to main content
Version: v5.6.1

2.2 Namespaces, composition and inputs

Included from Basic tier

After this you can: organise actions in namespaces, compose one action from others with uses:, and pass inputs, outputs and expressions between steps.

Before you start — tick each one:

  • Lesson 2.1 — Your first action (opschain learn 2.1)

In the GUI: the Run form renders an action's declared typed inputs as form fields — an enum as a select over its values, a boolean as a toggle.

One action per file is where lesson 2.1 left you. Real catalogs are dozens of actions that call each other and hand data along. This lesson adds the three things that make that tidy: namespaces to organise, uses: to compose, and inputs, outputs and expressions to pass data — every one of them checked when the action compiles, before anything runs.

1. Namespaces​

Directories under .opschain/actions become namespaces. The file .opschain/actions/db/start.yaml defines the action db:start:

action: start # just the leaf — the db namespace comes from the directory
steps:
- name: boot
run: echo booting the database
opschain runs create change db:start --env dev --local
[db:start/boot] booting the database
2026-11-07T00:25:48Z db:start/boot running -> success
2026-11-07T00:25:48Z db:start running -> success

run 01a1b365-b243-77fc-8ec3-48140b4be67f success (0.1s)

Namespaces keep a growing catalog tidy: db:start, db:stop, web:deploy. Inside a namespace, a bare uses: reference resolves locally first, then walks up to the root — so db:start can say uses: stop and get db:stop.

2. Composition with uses:​

A step whose uses: names another action inlines that action's steps as its children — build small actions, compose big ones:

# .opschain/actions/release.yaml
action: release
steps:
- name: build
run: echo building
- name: publish everywhere
uses: publish_fanout
# .opschain/actions/publish_fanout.yaml
action: publish_fanout
steps:
- stage: fan
strategy: parallel
steps:
- name: site_a
run: echo published site a
- name: site_b
run: echo published site b

--plan shows the composed tree, the callee's steps nested under the calling step:

opschain runs create change release --env dev --plan --local
· release
├─ · build
└─ · publish everywhere
└─ · fan
├─ · site_a
└─ · site_b

Cycles are rejected with the chain named.

Where uses: pulls work INTO this change (one approval envelope, one tree), a change: step pushes work OUT to another node as its own governed child change — the right shape when the foreign action must run under its own node's properties, template pin, approvals and entitlements:

- name: deploy_db
change:
target: ../assets/database # node path, absolute or project-relative
action: db:deploy # resolved against the TARGET's catalog
property_overrides: { opschain: { release: "${{ inputs.version }}" } }

The step is fulfilled server-side: when it is reached the server creates the child change on the target (through the ordinary create funnel — the target's own gates apply), waits for it, completes with the child's outcome and republishes the child's outputs as ${{ steps.deploy_db.outputs.* }}. The child renders as a hop in the change's correlation chain (lesson 5.1). One target per step, named literally — fan-out across targets stays a workflow capability — and the target and action are checked when the change is created (a dangling reference is an immediate named refusal) and again when the step is reached. when: and on_error: apply as on run: steps; retries: does not (retry the child change instead). Child-of-child composition is capped by the orchestration.change_depth_limit setting (default 5); past it the step fails naming the chain. change: steps need the server — local mode refuses them by name.

MintPress pack composition (uses: mintpress#<ref>, requires the packs:mintpress entitlement): see opschain docs packs-mintpress.

3. Inputs​

Inputs make a composed action parameterisable. The callee declares them (typed: string, number, boolean, enum with values, secret, list<string>), the call site binds them with with:, and the binding validates when the action compiles — before anything runs:

# callee: declares its contract
action: greet
inputs:
who: { required: true }
greeting: { default: "hello" }
steps:
- name: say
run: echo "${{ inputs.greeting }}, ${{ inputs.who }}"

# call site
steps:
- name: greet the team
uses: greet
with: { who: "ops" }
[release/greet the team/say] hello, ops

Leave the required input unbound and the plan refuses, naming the step, the action and the input:

Error: action release: step greet the team calls action greet without its required input "who" — bind it with a with: entry at the call site

An unknown with: name, a missing required input, or a literal value that violates the declared type (a non-number for a number, a value outside an enum's values, a secret that is not a secret-vault:// reference) is a named error of the same kind. A with: value carrying ${{ … }} resolves at the consuming step's dispatch instead. A top-level action's inputs are supplied at run time with -i key=value (lesson 2.5 shows the refusal when one is missing).

4. Outputs​

Outputs pass data forward. A step publishes with opschain step set-output <key> <value> (add --sensitive for values that must be masked in logs — a sensitive output is also stored encrypted at rest, on a server's store with its keys and on your local store with its own generated keyset), and any later step reads it:

steps:
- name: produce
run: opschain step set-output build_id "$(date +%s)"
- name: consume
run: echo "deploying build ${{ steps.produce.outputs.build_id }}"
2026-11-07T00:25:49Z release/produce running -> success
2026-11-07T00:25:49Z release/consume queued -> running
[release/consume] deploying build 1794011149
2026-11-07T00:25:49Z release/consume running -> success

For a value too large to pass as an argument, --value-file <path> reads it from a file (- reads stdin); a step's outputs share a 64 KiB budget, and an over-budget value is refused at the write with the budget named — outputs are metadata, and anything larger travels as a declared step artifact (artifacts: on the step — lesson 3.6). Files need no channel at all: every step of a change run executes in the run's shared workspace — locally a git worktree of your repository, so git describe and friends work — and a file one step writes is on disk for the next. The workspace is swept at run end, cancel included; --keep-workspace on a local run retains it for debugging.

5. Expressions​

Expressions resolve before a step's command runs:

  • ${{ properties.<path> }} — the merged configuration view (lesson 1.3).
  • ${{ context.<path> }} — the run's own context, e.g. ${{ context.change.action }}.
  • ${{ steps.<name>.outputs.<key> }} — an earlier step's output.

A reference to a path that does not exist fails the step with the reference named — a typo never silently becomes an empty string. Ordinary shell syntax ($VAR, ${VAR}) passes through untouched.

Spans are full CEL expressions, so logic goes beyond substitution: comparisons, ternaries, has(), the filter/map/exists macros, and chunk/uniq over lists. Two highlights:

  • when: on any step gates it at entry — false records the step skipped with the evaluated condition and its operand values; an evaluation failure fails the step loudly:

    - name: snapshot
    run: dbctl snapshot
    when: steps.precheck.outputs.role == 'primary'
  • estate('<node path>') reads another node's converged properties (read-authorised at entry, versions recorded, vault references never resolved):

    - name: configure
    run: wire --db ${{ estate('/projects/core/environments/prod/assets/db-01').properties.listener.host }}

6. When a step fails​

Two more primitives shape the aftermath. A step whose when: calls error() (or always()) runs because the scope failed — the abort cascade spares it for compensation/cleanup, and the run still finishes error (with error_handled recorded, so nothing is masked). And retries: {attempts, backoff} re-executes a failing step in place before any of that:

- name: patch
run: dbctl patch
retries: { attempts: 3, backoff: 30s }
- name: rollback
run: dbctl rollback
when: error()
- name: report
run: notify ops
when: always()

The scope's state is readable too. success() (true while no sibling has failed) is the explicit spelling of the condition every ordinary step carries implicitly, and steps.<name>.status reads one sibling's outcome ('success' | 'error' | 'skipped' | 'cancelled') for surgical conditions — when: error() && steps.patch.status == 'error' runs a handler only when it was patch that failed. The status functions are legal in when: only.

A step that declares no retries: of its own inherits the orchestration.retries settings ladder — global → project → environment → asset, nearest wins, resolved once at run creation — and each automatic retry records the level that drove it as its policy_source, so the run's record says which policy re-ran the step.

A run step may also declare its error policy inline with on_error:: continue lets the scope proceed past the step's failure (the step still records error honestly), and the object form names a compensating action — sugar that desugars into exactly the hand-written handler above, as a sibling step <step>.on_error gated by when: error('<step>'):

- name: patch
run: dbctl patch
retries: { attempts: 3, backoff: 30s }
on_error:
uses: rollback
with:
reason: ${{ error.message }}
then: fail # fail (default) | continue

Inside the handler's scope — its with: values and everything the invoked action runs — error.step, error.status and error.message carry the failed step's recorded error, beside its published outputs. Retries exhaust first; then: fail keeps the run finishing error, then: continue behaves as ignore-after-handler-success; a handler that itself fails is terminal, and an action invoked as a handler may not carry an object-form on_error of its own.

7. Fan out over a list​

Action-file steps fan out too: a run: or stage: step may declare for_each: — a list expression evaluated at the step's entry; each element becomes an ordinary child step, binding item/index (or an as: alias and <as>_index) into everything the child evaluates:

- name: ping
run: ping -c1 ${{ host }}
for_each: ${{ properties.hosts }}
as: host

An empty list records the step skipped; an expansion past the orchestration.fan_out_limit setting (default 50) fails loudly naming the cap; nested fan-outs must bind distinct as: names. A fanned-out stage instantiates its nested steps once per element.

Every expression is compile-checked when the action compiles (error() and friends are legal only in when:), and opschain docs expressions is the full reference. Cross-node property layering itself is lesson 1.3's topic.

Check yourself​

  • How does a step read the output of the step before it, and what does a missing required input do to a plan?

What you learned​

  • A directory under .opschain/actions is a namespace, and db:start is the action in db/start.yaml.
  • uses: inlines another action's steps under the calling step; change: pushes work out to another node as a governed child change.
  • Inputs are declared by the callee, bound with with:, and validated when the action compiles — a missing one refuses the plan by name.
  • opschain step set-output publishes a value; ${{ steps.<name>.outputs.<key> }}, ${{ properties.… }} and when: are expressions resolved before each step runs.

Next: lesson 2.3 — Waits and gates — the policy way (opschain learn 2.3), or back to your track (opschain learn).