4.1 Anatomy and the step kinds
Included from Basic tier
After this you can: read a workflow definition and name every step kind it may contain, and what each does.
Before you start — tick each one:
- Lesson 2.2 — Namespaces, composition and inputs (
opschain learn 2.2)
In the GUI: the Workflows tab on a project page lists the stored definitions; running one opens the same Run form as an action, typed inputs rendered as fields.
A change runs one action on one node. A workflow orchestrates many — start the database, then SAP, then the web tier; pause for an approval in the middle; fan one action across every asset of a kind. This lesson is a reading lesson: by the end you can open any workflow definition and say what each step does, where its target comes from, and how data moves between steps. Lesson 4.2 stores and runs one.
1. The four step kinds
A workflow is a list of steps, each one of four types:
change— run an action on a target node (the workhorse).workflow— nest another stored workflow.wait— pause for a human. (Governed sign-off — named approvers, quorum, audit — is an approval policy on the server, not a key in the YAML: lesson 4.4 and lesson 10.1.)stage— group nested steps and set theirrun_as:(sequentialorparallel). (Workflows spell this knobrun_as:; action files spell the same thingstrategy:on their stage steps — lesson 2.1.)
name: Environment Start
description: Starts all assets in dependency order
steps:
- type: change
name: Start database
target: /projects/acme/environments/dev/assets/database
action: database:start
- type: wait
name: Confirm before the app tier
- type: stage
name: App tier
run_as: parallel
children:
- type: change
name: Start SAP
target: /projects/acme/environments/dev/assets/sap
action: sap:start
- type: change
name: Start Web
target: /projects/acme/environments/dev/assets/web
action: web:start
A change step may also take targets: (a list) to fan the same action
across several nodes, and property_overrides: to shape each run.
2. Where a change step's action comes from
An asset target runs its template's catalog actions (lesson 3.4). A
static action on an asset target is checked against that catalog
eagerly — at workflow publish and at run creation — so a typo is a named
error up front instead of a mid-run step failure. (Targeting a
MintPress-backed asset's catalog actions requires the packs:mintpress
entitlement — opschain docs packs-mintpress.)
A change step's target: may also name a project or environment
node. There is no template or stored catalog behind those nodes, so the
child is a discovered git-remote change: the server pins a revision
of the target project's repository, runs the named action, and the
action's own execution reports the step tree — exactly what lesson 3.1's
first change did. Two optional fields select the repository identity,
both defaulted when absent:
- type: change
name: snapshot config
target: /projects/acme # or environments/dev, etc.
action: backup:export_config
git_remote_name: origin # default: the project's sole configured remote
git_rev: release-1 # default: HEAD (the remote's default branch)
At workflow publish and run creation the target must exist and have a
configured git remote (a project with several remotes needs an explicit
git_remote_name:); template_version: is a named error here, and
git_remote_name:/git_rev: are named errors on an asset target, whose
template supplies the git identity. The action itself is repository
content discovered by running, so it is validated only when the step is
reached — a dangling action fails the child change, which fails the step
loudly. The child create authorises against the target node as the run's
creator, exactly like a direct change create.
Declarative YAML action files are the default authoring lane — they
compile, validate and run on any runner. An actions.rb is the
SDK/compatibility lane, applicable only to existing MintPress customers
and those using the MintPress SDK or the packs:mintpress feature: it
needs a Ruby-capable runner, and the catalog marks such entries
runner_requirement: "ruby".
3. Conditions: when:
Any step may carry when: — a CEL boolean evaluated when the step is
reached, over properties (plus run.*, node.* and estate()); false
skips the step (and its children) with the verdict and operand values
recorded on it. On a change step with a static target, properties
(for when: and the step's with: values alike) is the target's full
converged view — project → environment → asset, the git-committed layers
below the stored ones, exactly what the spawned change's own steps will
see — merged with the run's property_overrides; on wait, stage and
dynamic-target steps it is the project's properties merged with the run's
property_overrides:
- type: wait
name: Production sign-off
when: properties.env == 'production'
opschain docs expressions covers the expression language.
4. Typed run inputs
A workflow may declare typed run inputs beside its steps — parameters the caller supplies when the run starts, validated before anything is created:
inputs:
environment: { type: enum, values: [dev, staging, production], required: true }
batch_size: { type: number, default: 2 }
dry_run: { type: boolean, default: false }
project: { type: string, default: '${{ run.project }}' }
Types are string, number, boolean, enum (with values),
secret (vault references only — lesson 3.5) and list<string>. A
default is a literal or an expression over the run-creation scope
(run.* — the run.project default binds the owning project's code);
it evaluates once when the run is created and is recorded like a
supplied value. Steps read the resolved values as inputs.* —
when: !inputs.dry_run is the canonical gate. Secret input values are
masked as ****** in every run document. Inputs arrive from whoever
starts the run: -i on the CLI (lesson 4.2), the create API's inputs
object — or a trigger, whose action_params.inputs map may carry
${{ event.* }} templates resolved per firing (lesson 5.2).
5. Fan-out: for_each:
A change or stage step may fan out with for_each: — a list
expression evaluated when the step is reached; each element becomes an
ordinary child step, binding item/index (or the as: alias and
<as>_index). This is how a workflow runs over whatever the estate
actually holds instead of a hardcoded list:
- type: stage
name: shutdown applications
for_each: ${{ inputs.environments }}
as: env
run_as: parallel
children:
- type: change
name: apps
for_each: ${{ estate('/projects/acme/environments/' + env).assets
.filter(a, a.properties.Asset_Type == 'Software_Application') }}
as: app
run_as: parallel
target: ${{ app.path }}
action: ${{ app.code + ':shutdown' }}
estate(...).assets/.environments list only the nodes the run's
creator may read; an empty expansion records the step skipped, and an
expansion past the orchestration.fan_out_limit setting (default 50)
fails loudly. Dynamic target:/action: expressions resolve and
validate when the step is reached — a rendered action must exist in the
target's own action catalog. A template step's name: may carry the
same expressions (name: ${{ env }}-apps), so each expanded execution
is identifiable in the tree instead of apps/apps-1; a fan-out
stage's own dynamic name: renders onto each stage copy the same way
(name: check-${{ env.code }} → copies check-dev, check-prod),
while the declared stage keeps the authored literal. Only change and
stage steps fan out: a wait step gates people and a nested-workflow
step pins one run, so neither takes for_each: — wrap them in a stage (a
named compile error says so) — and a change step cannot combine
for_each: with targets:. opschain docs expressions has the full
rules (nested fan-outs need distinct as: names).
6. Data between steps: outputs and with:
Steps pass data forward: an action that runs
opschain step set-output <key> <value> publishes outputs, and when the
spawned change finishes they surface to every later workflow step as
steps.<step-name>.outputs.* — in when:, for_each:, dynamic
targets/actions/names and property_overrides values alike. A value
that is JSON (a list of targets, an object) decodes, so a discovery
step can drive a fan-out:
- type: change
name: discover
target: /projects/acme/environments/dev/assets/db
action: fleet:discover # publishes targets=[{asset_path, code, …}]
- type: change
name: patch
run_as: parallel
for_each: ${{ steps.discover.outputs.targets }}
target: ${{ item.asset_path }}
action: ${{ item.code + ':patch' }}
- type: change
name: report
when: ${{ steps.patch.outputs.children.filter(c, c.result == 'patched').size() > 0 }}
target: /projects/acme/environments/dev/assets/db
action: fleet:report
A fan-out step's steps.<name>.outputs.children is the ordered list of
its expanded executions' outputs (each with its status, asset_path
and published keys); guard optional outputs with
has(steps.<n>.outputs.<key>); outputs survive restarts and run-level
retries — a carried successful step is never re-executed to recover
them. Two helpers round out the list toolkit: uniq(list) deduplicates
(first occurrences win) and chunk(list, n) splits into consecutive
sublists of at most n — for_each: ${{ chunk(steps.discover.outputs.targets, 5) }}
sweeps in batches of five. Outputs stay under a hard 64 KiB per-step
budget — a step with a larger product to hand forward stores it as an
artifact and passes the id (lesson 3.6).
Data also flows into a nested run as typed inputs: a change or
workflow step may carry with:, binding the invoked action's (or
nested workflow's) declared inputs: — values are expressions resolved
when the step is reached, and the nested run's own creation validates
them (an unknown name, missing required input or type mismatch fails the
step with the named error). property_overrides coexist unchanged;
with: is the typed, declared lane:
- type: change
name: patch
target: /projects/acme/environments/dev/assets/db
action: db:patch
with:
advisory_ids: ${{ steps.discover.outputs.ids }}
severity: high
Check yourself
- In the Environment Start definition above: which steps create work on other nodes, which one pauses for a person, and at what moment is each change step's
action:validated — publish time or reach time — and why does that differ between the asset targets here and a project target?
What you learned
- A workflow is
change,workflow,waitandstagesteps; stages setrun_as: sequential|parallel. - An asset target runs its template's catalog (validated eagerly); a project or environment target runs a discovered git-remote action (validated when reached).
when:skips a step with its verdict recorded; typedinputs:are validated before the run exists and read asinputs.*.for_each:fans a change or stage over a list the estate or an earlier step produced;steps.<name>.outputs.*andwith:move data between steps and into nested runs.
Next: lesson 4.2 — Store, browse, run (opschain learn 4.2), or back to your track (opschain learn).