Orchestration
Included from Basic tier
OpsChain work is authored in two YAML document kinds that share one
orchestration language. A YAML action defines what one node does —
the shell steps, composition and gates behind a verb like db:patch. A
YAML workflow sequences work across many nodes — changes, stages,
approval gates and nested workflows. Both compose through the same
primitives (when:, for_each:, retries:, on_error:, typed
inputs:/with:, step outputs) and the same expression layer: every
${{ … }} span is a CEL expression, compiled when the
document is compiled or published — a bad expression is a named error
before anything runs — and every runtime verdict is recorded on the
step, so plans, chains and triage read ordinary tree facts. There is no
scripting runtime inside the engine: expressions are pure (no I/O, no
time, no randomness) and cost-bounded.
Every YAML block on this page is a complete, loadable document — an action file or a local workflow file — and the repository's tests compile each one through the engine's own schema, composition and expression checks, so these examples cannot drift from the engine.
The model
- An action runs as a change on one node, and its steps form the change's step tree.
- A workflow runs as a workflow run whose change steps spawn real, individually-retryable changes; stages group steps sequentially or in parallel; wait steps park the run for a human.
- Either document may fan out (
for_each:), gate steps (when:), retry (retries:), declare error policy (on_error:), take typed run inputs (inputs:) and pass data forward (step outputs).
Authoring and running walkthroughs live in the runbooks:
opschain docs getting-started/2-1-your-first-action (actions) and
getting-started/4-1-anatomy-and-the-step-kinds (workflows). This page is the reference
map of the language itself.
YAML actions
One file under .opschain/actions/ defines one action; directories are
namespaces (db/start.yaml → db:start). Each step carries exactly one
of six kinds:
| Step kind | Declares | Does |
|---|---|---|
run: | a shell command | executes on the runner; may capture: stdout/stderr into outputs, set_properties:/remove_properties: on success, declare artifacts: collected at completion (opschain docs artifacts), pin image:/tools:, set timeout: |
uses: | an action reference | composes another action into this tree — a sibling YAML action (inlined at compile), an actions.rb# action or a mintpress# catalog action (see the integration lanes below) |
change: | target + action | invokes ANOTHER node's action as its own correlated child change (below) |
wait: | optional inputs | parks the change for a human; governed sign-off belongs in approval policies (opschain docs change-governance) — the legacy requires_approval_from: key still makes it an approval gate and stays enforced |
emit: | a custom event type | publishes a typed event onto the event stream, with templated with: data |
stage: | strategy: + nested steps: | groups steps sequential or parallel |
Both actions and workflows take an optional one-line summary: beside
description: — the two fields serve different audiences. summary: is
an imperative one-liner (at most 120 characters, single line — a
multi-line value is rejected at load by name) that actions list,
workflows list and the GUI's action-button captions render; when it is
absent, the first sentence of the description (else its first line,
ellipsis-truncated) stands in, so existing definitions render readably
with no churn. description: stays the full operator detail, printed by
the show views and detail panes, never truncated.
A minimal composed pair — the callee declares typed inputs: and
exports outputs:, the call site binds them with with: and reads the
exports under its own step name:
# .opschain/actions/greet.yaml
action: greet
description: Declares a typed contract its call sites bind
inputs:
who: { type: string, required: true }
greeting: { type: string, default: hello }
outputs:
message: ${{ steps.say.outputs.message }}
steps:
- name: say
run: opschain step set-output message "${{ inputs.greeting }}, ${{ inputs.who }}"
# .opschain/actions/welcome.yaml
action: welcome
steps:
- name: greet_the_team
uses: greet
with: { who: ops }
- name: announce
run: echo "${{ steps.greet_the_team.outputs.message }}"
The binding validates when the action compiles — an unknown with:
name, a missing required input, or a literal that violates the declared
type (a non-number for a number, an enum violation, a secret that is
not a secret-vault:// reference) is a named error before anything
runs. Composition is cycle-checked and depth-capped; a composed callee's
internal step names stay private to its subtree, and the call-site step
republishes exactly the callee's declared outputs: exports (or, with
outputs: passthrough, every internal step's outputs).
YAML workflows
A workflow is a list of steps of four types — change (run an action on
a target node), workflow (nest another stored workflow, by its
catalog identity), wait and stage (nested children: ordered by
run_as: sequential|parallel; action files spell the same knob
strategy:). Locally a workflow file lives in .opschain/workflows/
and carries its identity as a workflow: <code> header; on the server,
opschain workflows import stores the same definition as an immutable,
per-project version.
# .opschain/workflows/env-start.yaml
workflow: env-start
name: Environment start
steps:
- type: change
name: start_database
target: /projects/acme/environments/dev/assets/database
action: database:start
- type: wait
name: confirm_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's target: is usually an asset, whose pinned template
supplies the action catalog — a static action on an asset target is
checked against that catalog at publish and at run creation. A target
may also be a project or environment node: the child is then a
discovered git-remote change, with optional git_remote_name: /
git_rev: selecting the repository identity, and the action validates
only when the step is reached (it is repository content). A change step
may take targets: (a list) to fan the same action across several
nodes, property_overrides: to shape the spawned change, and with: to
supply the invoked action's (or nested workflow's) typed inputs —
values are expressions rendered when the step is reached, validated by
the nested run's own creation.
The shared primitives
The full semantics of each primitive — timing, recorded verdicts, scope rules — live on the expressions page; this is the map.
when:— any step, in both document kinds, takes a CEL boolean evaluated at step entry. False records the step skipped (children included) with the verdict and evaluated operand values; an evaluation failure fails the step loudly.- Status functions and failure paths —
success(),error(),error('<step>'),cancelled()andalways()are legal inwhen:only, and read the enclosing scope's state. A step whosewhen:callserror()/always()becomes a failure-path step: the abort cascade spares it and dispatches it once its non-handler siblings settle — compensation and cleanup — while the run still finaliseserror(recorded aserror_handled; the failure is never masked). A trailingwhen: always()step is the finally block.steps.<name>.statusreads one sibling's outcome for surgical conditions. retries:—{attempts, backoff}re-executes a failing step in place before any failure policy applies, every attempt recorded. Legal onrun:andactions.rb#steps in action files, and onchange/workflowsteps in workflows (each automatic retry spawns a fresh nested run). A step without its own policy inherits theorchestration.retriessettings ladder (global → project → environment → asset, nearest wins), audited as each retry'spolicy_source. See settings.on_error:— the inline error policy onrun:steps and change steps.continuelets the scope proceed past the step's failure (the step still recordserrorhonestly — one bad fan-out element never stops a sweep); the object form{uses: <action>, with: {…}, then: fail|continue}names a compensating action, desugared into a sibling handler step gated bywhen: error('<step>')with the failed step's recorded error bound aserror.step/error.status/error.messagein handler scope.retries:exhaust first;then: fail(the default) keeps the run finishingerror; a handler that itself fails is terminal, and handlers never nest.for_each:— a list-valued CEL expression evaluated at the step's entry fans the step out: each element becomes an ordinary child step, bindingitem/index(or theas:alias and<as>_index) into everything the child evaluates — targets, actions, commands,with:values, nested conditions and nested fan-outs (which must bind distinct names). Legal onrun:andstage:steps in action files and onchangeandstagesteps in workflows;run_as:orders the expansion. An empty expansion records a skip; an expansion past theorchestration.fan_out_limitsettings ladder (default 50) fails loudly naming the cap. A workflow template step'sname:may carry the bindings (name: patch-${{ app.code }}), so each expanded execution identifies its element in the tree.- Typed run inputs — workflows and top-level actions declare
inputs:(string,number,boolean,enumwithvalues,secret— vault references only, masked everywhere, an empty value meaning "not supplied" — andlist<string>), validated before the run exists and recorded on it. Supplied with-i key=value, the create APIs'inputsobject, a trigger's templatedaction_params.inputs, or a calling step'swith:. Defaults are literals or expressions over the run-creation scope (default: '${{ run.project }}'). - Step outputs — a step publishes with
opschain step set-output <key> <value>(--sensitivemasks); later steps read${{ steps.<name>.outputs.<key> }}. At workflow scope a change step rolls its nested run's root-scope outputs up for every later step, JSON-shaped values decode, and a fan-out parent exposesoutputs.children— the ordered list of its expansions' outputs objects (status,asset_path,code,item/indexand the published keys), so discovery steps can drive sweeps and report steps can filter results. Guard optional keys withhas(). - Wait steps and approvals — a wait step parks the run,
when:makes it conditional, and it is answered withopschain runs continue|approve|reject. Governed sign-off belongs in approval policies (opschain docs change-governance): a policy at the owning path holds matching runs for approval before they start, with named approvers, quorum and audit. The legacy step keyrequires_approval_from:(user_names:/ldap_groups:) restricts who may answer the wait mid-run; it stays supported and enforced, is superseded by approval policies, andopschain approval-policies convert-step-gatesinventories every use and creates the equivalent policies (your YAML is never edited). estate('<node path>')— reads another node's converged properties from any expression, RBAC-checked at entry with the run creator's own read authority, snapshot-at-entry, every referenced property document's version recorded in the step's audit; secrets never cross (vault references arrive as reference strings).estate(<path>).assets/.environmentsenumerate the node's direct children as{code, name, path, properties}descriptors — the raw material for estate-driven fan-outs.- Plans —
--plan(and the plan endpoints) compiles the same payload as create with no run created: everything decidable up front renders concretely (verdicts, resolved fan-out elements with their targets and actions), output-dependent spans render symbolically with the producing step named. Always advisory — the run re-evaluates at entry.
CEL out of the box
Expressions evaluate over these namespaces (full table and scope rules on the expressions page):
| Namespace | Holds |
|---|---|
properties.* | the step's merged properties view (project → environment → asset) |
context.* | the change run's step context document |
steps.<name>.outputs.*, steps.<name>.status | preceding steps' published outputs and (in when: only) a sibling's status |
inputs.* | the run's typed inputs at workflow/root scope; a composed call site's with: bindings inside the callee |
run.*, node.* | run metadata (run.id, run.action, run.project, run.metadata); the target node's path/code |
error.* | the failed step's step/status/message — bound only inside an on_error handler's scope |
item/index (or as: aliases) | the current fan-out element and position, inside a for_each: scope only |
The function surface is deliberately small:
- Standard CEL — comparisons, boolean logic, arithmetic, ternaries
(
a ? b : c),in,size(), type conversions (int(),string(),double()), and the comprehension macroshas(),filter,map,exists,exists_oneandall. Numeric comparisons work across int/double. - The CEL strings extension —
split,join,replace,substring,trim,indexOf,lowerAscii/upperAsciiand friends. - The CEL lists extension — list helpers such as
sliceandflatten. estate('<node path>')— the cross-node properties read above.chunk(list, n)(alsolist.chunk(n)) — consecutive sublists of at mostnelements, in order: sweep in batches.uniq(list)(alsolist.uniq()) — removes duplicates, first occurrences winning.success()/error()/error('<step>')/cancelled()/always()— the scope-status functions, legal inwhen:only.
Evaluation is pure and cost-bounded; a missing key, a type error or a
runaway comprehension fails the step loudly (guard optional data with
has()), and operand values matching the run's mask set record as
******. Rendering into a command string: strings splice verbatim,
booleans and numbers format plainly, null renders empty, lists and
maps render as JSON.
Integration lanes
uses: is one vocabulary over three backends, and the change: step /
workflow change step extend the same reach across nodes. What runs where:
| Reference | Backend | Resolved | Runs | with: |
|---|---|---|---|---|
uses: <action> | a sibling YAML action | inlined at compile, cycle-checked | the callee's own steps, in this change | yes — binds the callee's declared inputs: |
uses: actions.rb#<action> | the repository's actions.rb (the Ruby SDK lane) | format-checked at compile; the action resolves on the runner | opschain-action <action> on a Ruby-capable (opschain-core) runner | no — actions.rb actions take no typed inputs |
uses: mintpress#<ref> | the asset's stored MintPress catalog | grafted at change creation, server-side; a dangling reference is a named 422 | the grafted subtree on the Ruby runner lane, exactly as the catalog action runs directly | no — MintPress actions are property-driven; use property_overrides |
change: step / workflow change step | ANOTHER node's catalog | eagerly where static, authoritatively when reached | a correlated child change under the target's own properties, template pin, approvals and entitlements | yes — supplies the target action's typed inputs |
YAML → actions.rb (the Ruby lane)
The Ruby lane is only applicable to existing MintPress customers and
those using the MintPress SDK or the packs:mintpress feature.
Declarative YAML action files are the default authoring lane — they
compile, validate and run on any runner. actions.rb remains the
SDK/compatibility lane, and uses: actions.rb#<action> bridges to it:
the step dispatches the named actions.rb action onto a Ruby-capable
runner (the action catalog marks such actions with
runner_requirement: "ruby", and placement routes the work to a runner
that carries the toolchain). retries: applies — the engine re-executes
the bridge step in place — and a bridged action that reports child steps
while it runs has those children adopted into the tree and executed on
the same Ruby lane. A plain run: step that attempts the same is
refused loudly: only Ruby-lane steps may report children.
# .opschain/actions/nightly_maintenance.yaml
action: nightly_maintenance
steps:
- name: quiesce
run: ./scripts/quiesce.sh
- name: rotate_keys
uses: actions.rb#security:rotate_keys
retries: { attempts: 2, backoff: 20s }
YAML → MintPress (uses: mintpress#<ref>)
On a MintPress-backed asset (requires the packs:mintpress
entitlement), a YAML action may graft one of the asset's stored MintPress
catalog actions into its own tree. <ref> is a catalog full path
(Binaries/jdk), the wire identity (mintmodel:jdk) or the bare action
name, matched case-insensitively. The referenced subtree is grafted at
change creation, server-side — the plan shows the grafted children, and
they execute exactly as running the catalog action directly would. The
MintPress validity gates apply unchanged, and the scheme needs the
asset's stored catalog: project/environment changes, local runs and
fan-out expansions refuse it by name. See the
MintPress pack reference.
# .opschain/actions/provision_stack.yaml
action: provision_stack
steps:
- name: preflight_check
run: ./scripts/preflight.sh
- name: bring_up
uses: "mintpress#Startup"
- name: verify
run: ./scripts/verify.sh
Cross-node: the change: step
Where uses: pulls work INTO this change (one tree, one approval
envelope), 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. The target and action are literal in action files (named
node path, action resolved against the TARGET's catalog — fan-out across
targets stays a workflow capability); workflow change steps additionally
accept dynamic target:/action: expressions, resolved and validated
when the step is reached. The child renders as a hop in the change's
correlation chain, the step completes with the child's outcome and
republishes the child's root-scope outputs as
${{ steps.<name>.outputs.* }}, and child-of-child recursion is capped
by the orchestration.change_depth_limit setting (default 5).
# .opschain/actions/release.yaml
action: release
inputs:
version: { type: string, default: latest }
steps:
- name: build
run: make dist
- name: deploy_db
change:
target: ../assets/database
action: db:deploy
property_overrides: { opschain: { release: "${{ inputs.version }}" } }
- name: verify_release
run: ./verify.sh
The worked example — a quarterly patch, end to end
One scenario that exercises the whole language: discover what needs
patching, gate on a human unless the run is unattended, sweep every
non-production environment's applications in parallel, patch the
project-level infrastructure with retries and a compensating rollback,
and report — the per-asset work composing YAML, actions.rb and
MintPress steps. First the per-asset action and its callees, authored in
the application repository:
# .opschain/actions/preflight.yaml
action: preflight
description: Eligibility checks before patching
inputs:
severity: { type: enum, values: [low, high, critical], required: true }
outputs:
eligible: ${{ steps.decide.outputs.eligible }}
steps:
- name: decide
run: opschain step set-output eligible "$(patchctl eligible --severity ${{ inputs.severity }})"
# .opschain/actions/rollback.yaml
action: rollback
description: Compensating handler - restores the pre-patch snapshot
inputs:
reason: { type: string, default: '' }
steps:
- name: restore
run: patchctl rollback --reason "${{ inputs.reason }}"
# .opschain/actions/patch.yaml
action: patch
description: Patch one asset and verify it afterwards
inputs:
advisory_ids: { type: string, default: '' }
severity: { type: enum, values: [low, high, critical], default: high }
events:
emits: [patching:asset_patched]
outputs:
result: ${{ steps.verify.outputs.result }}
steps:
- name: preflight
uses: preflight
with:
severity: ${{ inputs.severity }}
- name: silence_monitoring
uses: actions.rb#monitoring:silence
retries: { attempts: 3, backoff: 10s }
- name: apply
run: patchctl apply --advisories "${{ inputs.advisory_ids }}"
when: steps.preflight.outputs.eligible == 'true'
timeout: 30m
capture: { stdout: true }
retries: { attempts: 2, backoff: 30s }
on_error:
uses: rollback
with:
reason: ${{ error.message }}
then: fail
- name: restart
uses: "mintpress#Startup"
- name: smoke_endpoints
run: curl --fail ${{ endpoint }}
for_each: ${{ properties.patching.endpoints }}
as: endpoint
run_as: parallel
on_error: continue
- name: verify
run: opschain step set-output result "$(patchctl status)"
set_properties:
asset: { patching.last_result: verified }
- name: snapshot_config
change:
target: ../assets/config-store
action: config:snapshot
property_overrides: { opschain: { reason: post-patch } }
- emit: patching:asset_patched
with:
severity: ${{ inputs.severity }}
- name: cleanup
run: patchctl cleanup
when: always()
Then the workflow that sweeps the estate with it:
# .opschain/workflows/quarterly-patch.yaml
workflow: quarterly-patch
name: Quarterly platform patch
description: Discover, gate, patch and report across the estate
inputs:
project: { type: string, default: '${{ run.project }}' }
environments: { type: list<string>, default: [] }
advisory_ids: { type: string, required: true }
severity: { type: enum, values: [low, high, critical], default: high }
unattended: { type: boolean, default: false }
steps:
- type: change
name: discover
target: /projects/acme/environments/dev/assets/fleet
action: fleet:discover
with:
severity: ${{ inputs.severity }}
- type: wait
name: approve_patch
when: ${{ !inputs.unattended }}
- type: stage
name: patch_environments
for_each: >-
${{ estate('/projects/' + inputs.project).environments
.filter(e, has(e.properties.Classification)
&& e.properties.Classification == 'Non-Production'
&& (size(inputs.environments) == 0
|| e.code in inputs.environments)) }}
as: env
run_as: parallel
children:
- type: change
name: patch-${{ app.code }}
for_each: >-
${{ estate(env.path).assets
.filter(a, has(a.properties.Asset_Type)
&& a.properties.Asset_Type == 'Software_Application') }}
as: app
run_as: parallel
target: ${{ app.path }}
action: patch
with:
advisory_ids: ${{ inputs.advisory_ids }}
severity: ${{ inputs.severity }}
on_error: continue
- type: change
name: patch_infrastructure
target: ${{ '/projects/' + inputs.project + '/assets/infrastructure' }}
action: infrastructure:patch
retries: { attempts: 2, backoff: 30s }
on_error:
uses: infrastructure:rollback
with:
reason: ${{ error.message }}
then: fail
- type: wait
name: confirm_reboot
when: >-
${{ has(steps.patch_infrastructure.outputs.reboot_required)
&& steps.patch_infrastructure.outputs.reboot_required }}
- type: workflow
name: report
target: /projects/acme/workflows/patch-report
with:
patched_assets: >-
${{ uniq(steps.patch_environments.outputs.children
.filter(c, c.status == 'success').map(c, c.asset_path)) }}
batches: ${{ chunk(steps.patch_environments.outputs.children.map(c, c.code), 5) }}
- type: change
name: notify_failure
target: /projects/acme/environments/dev/assets/fleet
action: fleet:notify
when: ${{ error() }}
- type: change
name: audit_record
target: /projects/acme/environments/dev/assets/fleet
action: fleet:audit
when: ${{ always() }}
Walking it through:
- Typed inputs bind first.
advisory_idsis required — a run without it is refused before anything is created;projectdefaults to'${{ run.project }}'(the owning project's code, bound once at run creation);environmentsis an optional narrowing list — empty means every Non-Production environment. All five surface to every step asinputs.*, and-i severity=criticalat start overrides a default. discoverspawns a real change on the fleet asset, itswith:supplying the target action's typed inputs. When it finalises, its root-scope outputs surface to every later step assteps.discover.outputs.*.approve_patchis a conditional checkpoint: a manual run parks mid-flow for a human to resume; an unattended run (a trigger mapping${{ event.* }}ontounattended: true) records the step as a skip with the verdict and operand values. To restrict WHO signs off — say thepatch-approversLDAP group — put an approval policy on the project naming the group as approvers (opschain docs change-governance): the policy holds the run for that group's approval before it starts, with quorum and audit.patch_environmentsfans out twice from the estate. The stage enumeratesestate(project).environmentsand keeps the Non-Production ones (thehas()guard silently excludes unclassified nodes — drop it for the strict posture where an undeclared node halts the sweep); each environment copy fans out again over that environment'sSoftware_Applicationassets. Both enumerations are RBAC-filtered with the run creator's read authority and recorded, with per-document property versions, in each step's audit. Each expanded change renders aspatch-<asset>in the tree, targetsapp.path, runs thepatchaction with the run's inputs passed down throughwith:— andon_error: continuemeans one failing asset recordserrorwithout stopping the sweep. The expansion is capped by theorchestration.fan_out_limitladder (default 50).- Inside each spawned
patchchange the whole per-node language is at work: a composedpreflightcallee with a typed enum binding; anactions.rb#bridge step with retries (Ruby lane); the gatedapplystep reading the callee's exported output, capturing stdout, retrying twice and compensating through itson_errorhandler (rollbackreceiveserror.messagein handler scope;then: failkeeps the failure honest); a grafted MintPressStartupsubtree; a fan-out smoke test overproperties.patching.endpoints; a property write on success; achange:step snapshotting a sibling node's config as a correlated child change; a custom event; and analways()cleanup. patch_infrastructuretargets a computed path — a project-level asset, so the infrastructure is patched once per run, after every environment stage settled (tree order is the happens-before guarantee). Its retries exhaust first; then the desugaredpatch_infrastructure.on_errorhandler runs the rollback action with the recorded error message;then: failstill finalises the runerrorwitherror_handledrecorded.confirm_rebootreads rolled-up outputs: the wait engages only when the patch change publishedreboot_required—has()keeps the condition defined when it did not.reportnests another stored workflow, itswith:built from the fan-out parent'soutputs.children—filter/mapselect the successfully patched asset paths,uniqdeduplicates andchunkbatches them in fives.notify_failureandaudit_recordare the failure path and the finally block:error()dispatches the notification only when the scope failed,always()runs the audit regardless — both spared by the abort cascade, with the run's final status staying honest.
Preview the whole thing with
opschain runs create workflow acme/quarterly-patch -i advisory_ids=ADV-123 --plan:
input-driven verdicts and estate-driven expansions render concretely,
with each child's resolved target and action; everything that depends on
published outputs renders symbolically, naming the producing step.
Related
- expressions — the full
${{ … }}contract: spans, scopes, verdicts, fan-out, outputs roll-up and plans. - settings — the
orchestration.*settings ladders (retries,fan_out_limit,change_depth_limit). - packs-mintpress — the MintPress graft and Ruby-lane child reporting in depth.
- concepts — where orchestration sits in the wider vocabulary.
- Runbooks:
opschain docs getting-started/2-1-your-first-actionandgetting-started/4-1-anatomy-and-the-step-kinds— authoring walkthroughs, local runs and the error-path drill.