YAML pipeline reference
A pipeline document defines work that cryosleep runs as one durable
workflow. Jobs in the same DAG layer run in parallel; each job is one
durable boundary. There is no version marker to write: a file with jobs:
at its top level is a pipeline, and the run it produces is recorded under
the workflow type cryosleep/pipeline/v1.
This is not the same document as a canvas graph, which starts version: cryosleep-graph/v1 and is written as nodes and edges. Both compile to the
same node graph at run time, and they overlap heavily, but neither converts
into the other; see pipelines and graphs
for which to reach for. To see a pipeline drawn as a graph, paste it into
the read-only preview at /pipelines.
Top level
Section titled “Top level”name: optional-display-nametags: [ci, release] # applied to every run; merged with cryo submit --tagif: 'event.ref == "refs/heads/main"' # trigger predicate; skip the run if falseconcurrency: # at most one running run per group group: deploy-prod policy: queue # queue | cancel-running | cancel-queued | skip queue_max: 100 # optional cap on waiting runsthrottle: # at most N runs START per period limit: 20 period: 1menv: REGION: us-east # merged into every shell and script job as $REGIONvars: REGION: us-east # constants for expressions: `${{ vars.REGION }}`outputs: # what the run hands back to whoever ran it sha: ${{ steps.build.output.sha }}jobs: build: … deploy: …tags:, if:, concurrency:, throttle:, env: and outputs: are
optional.
jobs: is required (use an empty map only for stub validation).
Insertion order in jobs: is significant - it’s the tiebreaker when
multiple jobs are simultaneously ready.
Top-level concurrency: (one running run per group)
Section titled “Top-level concurrency: (one running run per group)”Admission control for shapes where two overlapping runs are wrong -
deploys most of all. At most one run in a group runs at a time; the
policy: decides what happens to the rest when a new run arrives into
a busy group. It binds however the run was submitted - cryo submit, a
handler routing a push at the pipeline, a schedule tick, a rerun, or a
cryo spawn from a script.
One surface it does not bind: a child started by a workflow: job
or a graph workflow node. Those go straight to the engine, so a
document with a concurrency: block runs alongside the group’s holder
when it is invoked as a child. Put the group on the parent instead.
queue(the default) - the new run waits in a FIFO queue behind the running one and starts, in order, when its turn comes. Nothing is cancelled; every submitted deploy eventually runs, oldest first.cancel-running- the new run cancels the running member (superseded by <run-id>in its cancel reason) and takes over immediately. Newest wins; a stale deploy never finishes after a newer one.cancel-queued- the new run supersedes the waiting backlog (any already-queued runs are dropped) and takes the single queue slot behind the running member. Useful when only the latest pending change matters but you don’t want to interrupt what’s live.skip- no run is created at all. The submit answers 200 with{"status": "skipped"}and the group it lost to. The body still carries the id the server had minted; it is a correlation handle for that request and resolves to nothing, socryo submitprints no id and exits 0.
skip is the singleton policy. The other three all produce a run
eventually, so a noisy trigger against a slow group builds a backlog
whichever one you pick. Reach for it where a second run would be noise:
a reconcile that picks up the same state next time, a cache warm, a
nightly that a manual trigger raced.
A cryo spawn of a document that skips fails instead. The calling
script is waiting on a child id and there is none, so the error is the
only honest answer - which also means a retry: around that spawn turns
the policy into “wait for the group”, so don’t wrap one.
queue_max caps how many runs may wait (default 100). A submission
that would exceed it is rejected rather than queued, so an unbounded
backlog can’t build up. It has no effect under skip, which never
queues.
group: takes ${{ … }}, which is how you get one running run per
branch rather than one for the whole pipeline:
concurrency: group: 'ci-${{ has(input.ref) ? input.ref : "manual" }}' policy: cancel-runningIt is rendered when the run is created, against the trigger that
created it, so input and vars are the whole scope - admission
decides whether a job runs at all, so there is no steps.* yet and
naming one is an error rather than an empty group. A group that renders
empty is refused too, since it would serialize unrelated runs together.
The has() is not decoration. A field that doesn’t exist is an error
here, the same as anywhere else, and a manual cryo submit carries
{"type": "manual"} with no ref - so the bare ${{ input.ref }} form
works on a push and rejects the submit you were using to test it. Give
the manual case a group of its own.
A queued run reports its state as queued with its position in the
group, and shows up in the runs list like any other run. Cancel it to
drop it from the queue before it starts. Every member (running or
queued) carries the concurrency-group:<group> tag, so the runs list
filters to a group with the existing tag filter.
The holder and the queue are durable: they survive a server restart, so a run that was waiting still starts when its turn comes.
A signal sent to a queued run is held for it. A run that hasn’t started
has nothing to receive one, so cryo signal against it records the
signal and hands it over the moment the run is promoted - repeated
signals of the same name keep the latest payload, and different names
all arrive. Approve a queued deploy the moment you decide, in other
words, rather than waiting for it to start.
Top-level throttle: (how many runs start per period)
Section titled “Top-level throttle: (how many runs start per period)”concurrency: above bounds how many run at once. This bounds how
many start over time, across every run in the project. They are
different questions: four at a time is still hundreds an hour when each
one is quick, and a service that answers 429 is counting the second.
name: reindex # the budget's name, when `key:` is absentthrottle: limit: 20 period: 1m burst: 5 # optional, default 1 key: '${{ input.tenant }}' # optional; absent = one budget for this pipeline policy: queue # queue (default) | drop queue_max: 100 # queue policy onlyjobs: …Twenty runs start per minute. The twenty-first waits for its slot and then starts; it is a real run that will happen, not a lost one.
burst:is what an idle stretch buys. The default of 1 is strict spacing -limit: 20, period: 1mstarts one every three seconds and never two together.burst: 5lets five arrive at once after a quiet patch, then paces the rest. Idleness never banks more than the burst, so a pipeline that ran nothing all night doesn’t release the night’s budget at once in the morning.key:takes${{ … }}and is rendered at submit againstinputandvars, exactly likeconcurrency.group- same scope, and the same reason for it. That is how one pipeline holds a budget per tenant rather than one for everybody. A key that renders empty is refused.- Without a
key:, the budget is the document’sname:- one budget for this pipeline, and nothing else in the project shares it. Athrottle:in a document with neither is refused, because there would be nothing to count under but “everybody”. policy: droprefuses the submission instead of holding it: 429 with aRetry-After, and no run is created. Use it where the work is worthless late - a health probe, a cache warm - and a backlog is worse than a gap. The defaultqueueis what you want for anything a person asked for.queue_max:caps the waiting list (default 100); a submit past it is rejected rather than queued unboundedly.
A queued run reports queued and carries the throttle-key:<key> tag,
so the runs list filters to one budget. Cancel it to drop it before it
starts. The budget is durable - it survives a restart, and two server
replicas share it.
Both blocks have a request-level twin, which is how work that is not a pipeline gets admission control - a polyglot script, a graph and a single node have no document to declare either in:
cryo submit probe.sh --concurrency-group probes --concurrency-policy skipcryo submit reindex.sh --throttle 20/1m --throttle-key reindexThe request’s declaration beats the document’s. --throttle-key is
required outside a pipeline: the key defaults to the document’s name:,
and a script has none, so without it every keyless script in the project
would share one budget.
The order the gates run in
Section titled “The order the gates run in”A submit meets up to three of them, and the order is what makes the answers add up:
--idempotency-key, if there is one. A redelivery is answered with the run the first one made, so it never reaches the two below and costs neither a rate token nor a queue slot.throttle:- how many may start. A run held here is waiting for a slot and has not met its group yet.concurrency:- how many may run at once. A run held here has already spent its rate token; the token is not handed back, because a queued run is a real run that will happen.
A run refused outright by the group - a full queue, or policy: skip -
does get its token back, since no run exists to have spent it. And a run
that queues on the rate meets its group later, when its slot comes: it
is never waiting in both queues at once.
A rate the engine can’t honour as written is refused by cryo check and
by the run itself rather than quietly becoming a different rate: a zero
period, a limit finer than the millisecond the spacing is measured
in (10000 per 1s), or queue_max: 0 alongside policy: queue. A
limit that doesn’t divide the period rounds the spacing up, so the
rate you wrote is a ceiling.
Both blocks can apply to one pipeline. The throttle is decided first, so
“at most twenty a minute, and never two at once” is throttle: plus
concurrency:, and a run waits for a slot before it waits for the
group.
What it does not do: pace the items inside a single fan_out. That is
the node’s own rate, which bounds one
node’s dispatch. A pipeline can use both.
Top-level cancel_on: (abort on a bus event)
Section titled “Top-level cancel_on: (abort on a bus event)”cancel_on: events: [pr.closed]jobs: …The run is cancelled when a matching event lands on the project bus.
Matching is on the event type and its subject: the run
inherits the subject of the event that started it, and cancel_on
correlates to that subject automatically. So a CI run started by a
push to acme/web has subject acme/web, and cancel_on: [pr.closed]
cancels it only when acme/web’s pull request closes - a pr.closed
on a different repo leaves it running. You write nothing about the
correlation; the subject carries it (see
Events and handlers).
A run started by a direct submit has no subject, so its cancel_on
matches by type alone. And an event emitted without a subject (a
plain cryo emit deploy.freeze) is a broadcast - it reaches every run
watching that type, regardless of subject. That is how a project-wide
“stop” signal works: emit it unkeyed and every in-flight run watching
deploy.freeze cancels.
Registered when the run is submitted, so an event arriving at any point
in the run’s life takes effect; one-shot per event type. The cancel
reason records the event (cancel_on event "pr.closed").
Top-level if: (trigger predicate)
Section titled “Top-level if: (trigger predicate)”A CEL expression evaluated server-side when the event is
delivered, before any run is created. When it’s false the push is
acknowledged and nothing runs - the declarative gate that pure-YAML CI
does with on: / paths: / if:, here as one real expression
language (the same engine as a job’s if:). No agent is involved in
the decision.
The event is in scope as event:
| Field | Example |
|---|---|
event.type |
push |
event.ref |
refs/heads/main |
event.sha |
full commit sha |
event.repo |
repo addressing |
event.sender |
pushing user |
event.paths |
files the push touched, as a list |
event.paths_known |
false when the forge didn’t report a file list |
event.payload |
the delivery body, for anything not shaped above |
A job’s if: reads these same fields under input: a triggered run’s
input is the event that started it. See
filtering one job.
Gate on the ref:
# only run CI on main or release branchesif: 'event.ref == "refs/heads/main" || event.ref.startsWith("refs/heads/release/")'A reference to an unknown field (a typo) is an error, and an erroring
predicate runs the pipeline and logs the failure - a broken guard is a
loud misfire, never a push that vanishes. Evaluated only for
event-triggered runs; a manual cryo submit has no triggering event
and always runs.
paths: and paths_ignore:
Section titled “paths: and paths_ignore:”Filtering on the files a push touched, without writing the guard yourself:
paths: [api/**, "*.rs", Cargo.lock] # run only if one of these changedpaths_ignore: [docs/**] # skip if they are ALL that changedBoth are ANDed with each other and with if:, and both read the same
event scope at the same moment. Four pattern forms:
| Pattern | Matches |
|---|---|
dir/** |
anything under that directory |
*.ext / **/*.ext |
by extension, at any depth |
**/name |
that filename in any directory, root included |
Cargo.lock |
that exact path |
A pattern using * in any other position is rejected at submit
rather than compiled into something that quietly matches nothing. For
anything richer, write the if: yourself.
paths_ignore: inverts per file, not per push, so a push touching
docs/x.md and src/y.rs still runs. Skipping needs every file to
match.
Both fail open when the forge reported no file list
(event.paths_known is false). That is the reason to prefer the
shorthand over the hand-rolled version: event.paths.exists(...) over
the empty list a forge gives in that case is false, which stops building
the repo while looking like a filter that works.
Filtering one job
Section titled “Filtering one job”paths: decides whether the run happens. There is no shorthand for
one job - rebuild the web bundle when web/ changed, always run
lint - so write that job’s if: over input.paths:
jobs: lint: steps: [{ name: lint, run: make lint }] web: depends_on: [lint] if: ${{ success() && (!has(input.paths_known) || !input.paths_known || input.paths.exists(p, p.startsWith("web/"))) }} steps: [{ name: build, run: make web }]Three parts:
success()- anif:replaces the implicitsuccess()gate, so without thiswebbuilds on top of a failedlint. Every hand-writtenif:on a job withdepends_on:needs it.!has(input.paths_known)- a run with no push behind it carries nopathsat all, and reading an absent key raises rather than answering false. Without this guard,cryo submitof the pipeline fails the run withNo such key: paths- the filter works on every push and breaks the first time somebody runs it by hand. (CEL’s||absorbs the error once another operand is true, which is why this guard covers theinput.pathsread further along the same line.)!input.paths_known- a forge sometimes reports no file list at all (a large push, a force push).pathsis then empty,existsis false, and the job skips silently. Not knowing what changed is the moment to build everything, so the guard fails open. The top-levelpaths:shorthand applies this one for you.
Read the whole condition as “run unless we know it isn’t needed”.
Keep it on one line. A folded >- block whose continuation lines are
indented keeps those newlines in the stored expression, where they show
up in any error the condition raises.
startsWith covers a directory prefix; the other shapes the top-level
paths: accepts are one expression each - p.endsWith(".rs") for an
extension, p == "Cargo.lock" for an exact file, and
p == "Cargo.toml" || p.endsWith("/Cargo.toml") for a filename at any
depth.
For text you did not write - a model’s reply, a webhook field - the
string helpers are trim(), lowerAscii(), upperAscii(),
replace(from, to), indexOf(s), lastIndexOf(s), charAt(i) and
substring(start), alongside CEL’s own size(), contains,
startsWith, endsWith and matches(regex). Offsets count characters,
so a non-ASCII string never hands back an index that splits one. split
is not available yet.
A skipped job still satisfies its dependents, so a deploy that
depends_on: [web] runs even when web was filtered out. Give it the
same condition, or gate it on steps.web.status - see
Dependencies.
Every job declares exactly one of: steps:, approval:,
wait:, wait_for_event:,
workflow:, script:. Plus optional shared fields:
build: depends_on: [lint] # DAG edges if: 'steps.lint.output.ok' # CEL expression - skip if false concurrency: # serialize just this job across runs group: deploy retry: # re-run the job's activity on failure attempts: 3 # total attempts including the first backoff: 30s # delay before attempt 2; doubles after max_backoff: 5m # ceiling on that doubling (optional) exit_codes: [75] # retry only these exits (optional) requires: [build] # extra agent capabilities to claim this job agent: builder-2 # pin to one agent by id (see below) timeout: 30m # wall-clock ceiling for the job's activity continue_on_error: true # this job's failure doesn't fail the run outputs: tag: tag # name → key in the job's raw output env: BUILD_FLAVOR: release # job-level env (shell and script jobs) credentials: REGISTRY_PASS: zot-push # ENV_VAR → credential name (see below) steps: … # one of the six kindscredentials: names the credentials a job may reach, as
ENV_VAR: credential-name. Declare it at the top level to share across
jobs; a job-level entry with the same variable wins, exactly as env:
does.
Only the credential’s NAME is in the document and on the queue. The control plane resolves the value when an agent claims the job, masks it in the run’s logs, and hands the agent the value with no reference to where it came from. A name that does not resolve fails the job rather than running it unauthenticated, and a run triggered by an untrusted actor is refused credentials entirely - so a pull request from outside the project cannot reach one by declaring it, and a job that declares one fails rather than running with it missing.
That failure is the default because the alternative is worse: a step that runs anyway does something quietly different from what you wrote. Where a step genuinely works without its credential, say so and it runs:
jobs: lint: optional_when_untrusted: true # runs on fork PRs, without the token steps: - run: make lintIt is job-level only. A document-level default would combine with a
document-level credentials: block to degrade the privileged job too,
silently, which is the thing the refusal exists to prevent. It only waives
the refusal - the credential is still withheld, so this is never a way
for an outside contributor to obtain one. And it does nothing on a
trusted run, where the value is injected as usual.
Note that a top-level credentials: block merges into every job, so
one privileged token there stops the whole pipeline serving fork pull
requests. Declare it on the job that needs it instead; that is the fix,
rather than marking the whole document optional.
The value is a literal name, not an expression: it is resolved long after the document is rendered, so a per-run name could not be checked when you write it.
credentials: GITHUB_TOKEN: forge-status # every job in the pipeline
jobs: publish: credentials: REGISTRY_PASS: zot-push # this job as wellcontinue_on_error: true keeps the run’s final status green when this
job fails, and lets its dependents run. The job’s own status stays
failed, so an explicit status check still sees what happened. See
continue_on_error: below.
retry:’s backoff doubles on every attempt after the second, so a
long budget can spend a long time waiting - attempts: 6, backoff: 1m
waits 1m, 2m, 4m, 8m, 16m, half an hour in total. max_backoff: caps any
single wait, which keeps a generous retry budget from becoming a long
outage.
A job that ran more than once says so: the run page marks the row ×3
and cryo logs prints the count under the step. It counts every reason
the body ran again - a retry:, and a re-dispatch after the agent
holding the job died without reporting. What cryo logs prints is the
run that finished the step; the earlier ones are in the run’s raw byte
stream, which cryo logs <run-id> --follow serves in full even after
the run is over.
A control plane that restarts mid-ladder resumes the job with a fresh retry budget. The count comes through that with it, except in the gap between one run of the body ending and the next being dispatched, where a restart loses what came before. So a job that survived a deploy mid-retry occasionally reports fewer runs than it had; the output of all of them is in the stream either way.
exit_codes: narrows what is worth another attempt. Without it every
failure is retried, so a command that already decided your input is
wrong gets asked twice more, with the backoff in between:
deploy: retry: attempts: 4 backoff: 10s exit_codes: [75] # EX_TEMPFAIL: the far end was busy steps: - name: ship run: ./deploy.sh # exits 75 when the registry is throttling, # 64 when the manifest is malformedExit 75 spends the budget; exit 64 fails on the first attempt, because another go at a malformed manifest produces the same malformed manifest.
Only a process exit is matched. A job that failed without one - the
agent went away, the job hit its timeout:, something killed it - is
never selectively retried: nothing chose that code, so a list of codes
has nothing to say about it. An empty list is refused rather than read
as “any”, since it names no code and would make attempts: a lie.
The same rule decides where the key is worth writing. A job that runs a
process (steps:, script:) can carry it. A graph’s http node
cannot: a failed request fails with a message and no exit code, so the
condition never holds and the node stops retrying entirely. Saving one
is refused, and cryo check reports it, so you hear about it while
editing rather than during an incident. A graph saved before that check
existed keeps running - drop the key and the node retries any failure.
The code is reported by the agent that ran the job, so during a rollout
an agent still on an older build reports none, and a job with
exit_codes: gets no retries until that agent updates. It fails on the
first attempt rather than retrying something it shouldn’t, which is the
safe direction, but a run that would have recovered doesn’t.
retry:, requires:, agent:, and timeout: apply to the
activity-backed kinds (steps: and script:); they have no effect on
approval:, wait:, or workflow: jobs, which run in the workflow
process rather than on an agent. requires: adds capabilities on top
of the job’s base one - shell for a steps: job, polyglot for a
script: job - so the activity only dispatches to an agent
advertising all of them.
agent: pins the job to one agent by id, narrowing on top of
requires: rather than replacing it. Capabilities route work to
whichever machine can do it, which is what you want almost always.
Reach for agent: when the machine itself is the subject rather than
the executor - reading one runner’s journal, draining it before
maintenance, checking what its disk holds - because two runners
advertising the same capabilities are otherwise indistinguishable to a
job.
A pinned job waits for that agent and no other. It will not fail over
to a healthy runner, and it stays pending for as long as the agent is
offline, so pin deliberately and prefer requires: for ordinary work.
On a matrix: job the pin covers every combination: the sweep chooses
the work, not the machine.
jobs: journal: agent: builder-2 requires: [shell] steps: - name: read run: journalctl -u cryo-agent -b -1 --no-pager | tail -100timeout: (humantime, e.g. 30m) overrides the executing agent’s
default activity ceiling (1h on unattended agents) in either
direction; on expiry the agent kills the job’s process group and the
activity fails (subject to retry:). For how these relate to the
automatic recovery you get from durability, see
retries and timeouts.
continue_on_error: (absorb a job’s failure)
Section titled “continue_on_error: (absorb a job’s failure)”A job marked continue_on_error: true may fail without stopping the run.
The failure is absorbed at that job: the run can still finish completed,
and its dependents still run. That is what makes it different from a
job that just failed, whose dependents are dropped by the implicit
success() gate.
jobs: lint: continue_on_error: true # advisory: report it, don't block the ship steps: - { name: run, run: ./lint.sh } deploy: depends_on: [lint] # runs even when lint failed steps: - { name: go, run: ./deploy.sh }The job’s own status stays truthful, so a downstream job can still react to it explicitly:
audit: depends_on: [lint] if: ${{ steps.lint.status == 'failed' }} steps: - { name: file, run: ./open-ticket.sh }The two views are deliberately different. steps.lint.status reports
what happened - one of success, skipped, failed. success() and
failure() read what the job presents to its dependents, which is
success once the failure is absorbed - so if: ${{ failure() }} on a
dependent of an absorbing job does not fire. Use the explicit status
comparison when you want to branch on an absorbed failure.
Job-level concurrency: (serialize one job across runs)
Section titled “Job-level concurrency: (serialize one job across runs)”deploy: depends_on: [build, test] concurrency: group: deploy steps: …Where the top-level concurrency: gates whole runs at submit, a
job’s concurrency: serializes just that job’s body across runs. The
job takes a durable lease on group right before it runs and holds it
until the run finishes; a second run that reaches the same job waits
for the lease, while every other job in that run keeps going. So build
and test of many runs proceed in parallel and only the deploy job
takes turns - one deploy at a time without stalling the pipeline ahead
of it.
The lease is project-scoped, and it’s the same lease a script takes
with cryo lock <group>. A job’s
concurrency: { group: deploy } and a script’s cryo lock deploy in
the same project hold the same lock: whichever gets there first runs,
the other waits. A waiting job shows up as awaiting-lease in
cryo ls.
While it waits, the run is durably parked - a server restart doesn’t
lose its place, and the lease frees the moment the holding run ends
(or is reclaimed if that run vanishes). Only the FIFO queue order
applies here; the top-level policy: / queue_max: knobs don’t (a
job’s concurrency: takes only group:).
group: takes ${{ … }} holes, which is how the lease names the thing
being protected rather than the pipeline:
deploy: depends_on: [plan] concurrency: group: "deploy-${{ steps.plan.output.env }}" steps: …That is one lease per environment: staging deploys take turns among
themselves, production deploys among themselves, and the two never wait
on each other. Per-record leases work the same way
("import-${{ input.account_id }}").
The group is rendered when the job is about to run, so it reads
everything that exists by then - steps.* of upstream jobs, input,
vars, run. That is later than the top-level concurrency.group,
which is rendered at submit and has no steps.* to read.
Two ways it can go wrong. A field that isn’t there fails the job when the
group renders (No such key: ref), so guard an optional one the way the
top-level group does: ${{ has(input.ref) ? input.ref : "manual" }}. A
group that renders empty is refused, since it would serialize unrelated
runs together.
A matrix: job can’t read matrix. in its group: the lease is claimed
once for the whole sweep, before any combination exists, so the hole is
refused at submit rather than locking on a name that isn’t there yet.
Job kind: steps: (shell job)
Section titled “Job kind: steps: (shell job)”The whole script - all steps stitched together - runs as ONE
shell_run activity on one agent. Step markers make log segments
splittable in the UI but there’s no per-step durable boundary.
build: outputs: tag: tag steps: - name: install run: apt-get install -y curl env: DEBIAN_FRONTEND: noninteractive - name: emit run: | set -e cryo output set tag=v1.2.3A step has name, run, optional env. Per-step env is exported
inside the script’s per-step subshell, so it doesn’t leak into later
steps. A step-level env: value is a literal string, and a ${{ … }}
in one is rejected at submit rather than rendered. The value is quoted
into the step script when the pipeline compiles, and the expression
resolves later - so a rendered value containing a quote would break out
of quoting the compiler wrote. Put an interpolated value in job- or
workflow-level env:, which renders safely because each value becomes
its own template rather than script text.
The same care applies to a value you splice into a step body yourself:
${{ input.ref }} written directly into a command lands unescaped. Read
it from an env var inside the script ("$CI_TRIGGER_REF") instead.
Side channels
Section titled “Side channels”A shell step stages job output with cryo output set:
build: outputs: tag: tag # expose the staged keys under these names built: built steps: - name: emit run: | cryo output set tag="$(git describe --tags)" built=trueship: depends_on: [build] if: ${{ steps.build.output.built }} steps: - name: deploy run: ./deploy.sh "${{ steps.build.output.tag }}"Two hops, and both are needed. cryo output set stages a key; outputs:
is what publishes it. Stage a key the mapping doesn’t list and it stays
inside the job - a downstream reference to it fails the run with
steps.build.output.built` is not an output of job "build", which declares "tag" rather than resolving to empty.
Values are parsed as JSON when valid and kept as strings otherwise, so
built=true reaches if: as a boolean rather than the string "true".
They’re committed only if the job succeeds.
To add work decided at runtime, have a script: job generate a graph
and run it with cryo call graph --definition (see Dynamic
pipelines).
A shell step can also call cryo annotate to attach a markdown
note to the run (build summaries, coverage tables, lint findings),
and cryo emit to put an event on the project bus. Neither
suspends the step, so both are allowed here even though the durable
primitives below are not. See the annotations guide
and events and handlers.
env: and vars:
Section titled “env: and vars:”Two blocks, split by where the value is read.
env: is process configuration. It reaches the job’s process and a body
reads it as $NAME. It never appears in an expression. An operator can
also opt agent variables through to jobs, so the same name can hold
different values on different agents.
vars: is document configuration. It is readable in any expression as
vars.<NAME> and is never exported to a process. Values are JSON, so a
number compares as a number.
env: REGISTRY: ghcr.io # $REGISTRY in a step bodyvars: MIN_COVERAGE: 0.9 # ${{ vars.MIN_COVERAGE }} in a body or an if:
jobs: gate: if: 'vars.MIN_COVERAGE > 0.8' steps: - name: push run: docker push "$REGISTRY/app"The split is not a style preference. A job’s if: is evaluated before
the job is handed to an agent, so at that moment there is no agent and no
environment to read - and a value that can differ per agent could not
produce the same decision when a run is replayed. Anything a gate reads
therefore has to live in the pipeline itself.
Values that change from run to run are neither of these: they are the
run’s input, read as input.<field>.
Built-in env
Section titled “Built-in env”Every shell job gets:
CI=trueCI_RUN_ID=<workflow-id>CI_JOB_NAME=<job-name>
Built-ins are written last so user env can’t shadow them.
Template text in a step body
Section titled “Template text in a step body”${{ … }} is the only thing cryosleep reads in a run: body, and it is
not valid shell, so nothing else in the script can be mistaken for it.
Chart templates, Go templates and jinja pass through as written:
- name: render run: helm template . --set tag="{{ .Values.image.tag }}"An unclosed ${{ is an error, reported with the job and step named.
Secrets
Section titled “Secrets”Project secrets (cryo secret set) are injected as env vars into
every steps: and script: job; user-set env: wins on a name
conflict. Injected secret values are masked in job output -
chunk-wise in the live stream, completely in the captured output.
The masking is best-effort: it catches echoed values (set -x,
accidental prints), not transformed ones (base64 etc.).
Job kind: approval:
Section titled “Job kind: approval:”Parks on a signal in the workflow process - no agent slot held.
Approval submission JSON becomes the job’s “raw output”; the
job’s outputs: mapping picks keys from it.
review: depends_on: [build] outputs: env: env note: comment approval: prompt: "Deploy to production?" fields: - { name: env, type: select, options: [staging, production] } - { name: comment, type: text, required: false }Field validation runs server-side before signaling the workflow:
required, type-checked, select options, number min/max bounds.
Answered from the web UI’s inline form, or from a terminal with
cryo approve <run-id>, which asks for each declared field.
A graph’s approval node takes the same verb and the same field
definitions.
Job kind: wait:
Section titled “Job kind: wait:”A timer or a signal-wait. Workflow-process-only; no agent.
soak: depends_on: [deploy] wait: duration: 30m # timer: sleep this long, then succeed
promote: depends_on: [deploy] wait: signal: deploy-ok # block until this signal is delivered timeout: 1h # optional bound on the blockduration: (humantime) is the timer; signal: is the block, released
by cryo signal <run-id> deploy-ok. Declaring both, or neither, is
rejected at submit.
timeout: (humantime) is a bound on the signal: form, never a kind of
its own. Beside duration: it is rejected rather than ignored, since a
timer is bounded already. Bounded waits covers what
expiry does.
Bounded waits
Section titled “Bounded waits”wait: signal:, wait_for_event: and approval: all park on a signal,
and all three take a timeout: (humantime). Without one they wait
forever; a run parked on an approval nobody answers is the usual way a
pipeline goes quiet. wait: duration: is bounded already, so a
timeout: beside it is rejected rather than ignored.
On expiry the job succeeds - it does not fail. A timeout is one of two planned outcomes, and for a soak it is the outcome you wanted, so failing would make a healthy two-week soak a red run. Branch on the output instead:
soak: depends_on: [deploy] wait_for_event: { event: alerts.fired, timeout: 14d }
rollback: depends_on: [soak] if: ${{ !steps.soak.output.timed_out }} steps: - { name: undo, run: ./deploy.sh staging --rollback }
promote: depends_on: [soak] if: ${{ steps.soak.output.timed_out }} approval: { prompt: "Promote to production?", timeout: 3d }timed_out appears only on a job that declares a timeout:. A job
without one keeps exactly the output it has always produced, and reading
timed_out on such a job is rejected at submit - so if you remove a
timeout:, remove the if: that reads it in the same edit, and cryo check will tell you if you forget.
When the signal wins and its payload is an object - an approval
submission, an event envelope - the flag is merged into that object, so
an approval’s own fields stay where they were
(steps.gate.output.decision, not …output.payload.decision). An
approval field named timed_out is rejected, since the flag would
overwrite whatever the approver submitted for it. A
payload that is not an object has nowhere to merge into, so adding a
timeout: reshapes it: a bare "hello" becomes
{"timed_out": false, "payload": "hello"}.
The wait resolves once, atomically. A signal that arrives after the deadline has been recorded does not re-open the job on a later replay.
Job kind: wait_for_event:
Section titled “Job kind: wait_for_event:”Block until a matching event lands on the project bus - the PR flow that waits for review events, the deploy that waits for a downstream system to report in. Workflow-process-only; no agent held while waiting.
soak: depends_on: [deploy] wait_for_event: event: alerts.fired timeout: 14dWhich events the job accepts: when:
Section titled “Which events the job accepts: when:”Without one, the job takes the first event of its type, whatever it
contains. when: is a predicate over the envelope; an event it declines
does not resume the job, and the wait goes on:
hold: wait_for_event: event: order.placed when: ${{ event.payload.qty > 10 }}The scope is one name, event - the same envelope a handler’s --when
and the top-level if: read. The ${{ … }} wrapper is optional: the
predicate is evaluated in the control plane against an event this run
has not seen, so it is never a hole resolved from the run’s own scope.
A downstream if: is not a substitute. It runs after the job resumed,
so the event has already been taken and the wait cannot be resumed;
when: is what keeps the job waiting for the one it wants. Declining
costs the other listeners nothing - they still receive the event - and
does not extend timeout:, which runs from the first park. A predicate
that will not compile is refused by cryo check.
With no timeout: the job waits forever. With one, the job succeeds
either way and its output says which happened, so a dependent can branch
on the two outcomes. See bounded waits below - it is
how “no alert fired for two weeks” is written, and that phrasing matters:
wait_for_event: resumes when the event arrives, so on its own it
gates what follows on the thing having gone wrong.
Under the hood this is a signal-wait on the well-known name
event:<job-name>, with an event→signal bridge registered when the
run is submitted. Two consequences: an event that arrives before the
job starts (while earlier jobs run) still counts - signals are
durable - and a human can unblock the job manually with
cryo signal <run-id> event:<job-name>. The delivered signal payload
is the event envelope. One-shot: the job resumes on the first
matching event.
A job name that is not already a valid identifier is normalised for
this: anything other than a letter, digit or _ becomes _, and a
leading digit gets one prefixed. A wait-for-ship: job parks on
event:wait_for_ship, so that is the name to signal by hand. cryo status <run-id> reports the name a parked run is waiting on, under
substate.name.
A script: job’s cryo wait-event <type> is a wait of its own under a
longer name, so a wait_for_event: job and any number of scripts can
wait for one event type and each is handed the event when it arrives -
see polyglot scripts.
Matching is keyed the same way cancel_on: is: the wait correlates to
the run’s subject, so a run started for acme/web resumes on its
alerts.fired, not another repo’s. An event emitted without a subject
still resumes any run waiting on that type - so a human can always
unblock a wait with a plain cryo emit alerts.fired. See
Events and handlers.
Job kind: workflow:
Section titled “Job kind: workflow:”Spawn a child workflow as the body of this job. The composition seam - see Composition.
canary: depends_on: [build] outputs: verdict: verdict workflow: type: cryosleep/script/v1 # or cryosleep/pipeline/v1 script: | #!/usr/bin/env bash cryo step "smoke" -- bash -c 'echo OK' cryo output set verdict=okscript: is template-rendered against the parent’s
${{ steps.X.output.Y }} scope before submission. A child run starts
clean, so the parent’s env: is not in the child’s process - render any
value it needs in from the parent, or write it literally.
For cryosleep/script/v1 it’s the polyglot script body; for
cryosleep/pipeline/v1 it’s a YAML pipeline source, and what that child
hands back is whatever its own
document-level outputs:
declares.
Instead of type: + script:, a workflow: job can call a graph
or a single node-kind - the same targets a script’s call_graph /
call_node and the cryo call CLI reach. The child’s output becomes the
job’s output.
notify: workflow: node: # one node-kind kind: matrix.send_message # a builtin or <connector>.<action> config: { room: "!ops:example.com" } input: { body: "deploy done" }
deploy: workflow: graph: # a stored graph by name name: deploy version: 3 # optional; omitted runs the published version input: { ref: main }Exactly one of type: / graph: / node: per job.
input: is rendered against this pipeline before the child starts, so
it reads the parent’s scope (steps.*, vars.*, input.*) and the child
receives plain values:
deploy: depends_on: [build] workflow: graph: { name: deploy } input: ref: ${{ steps.build.output.sha }} # the value note: "built ${{ steps.build.output.sha }}" # a string retries: ${{ vars.MAX_RETRIES }} # stays a numberA hole that is the whole value keeps its JSON type, so retries reaches
the child as a number. A hole with text around it can only produce a
string. The same applies to a node: target’s config:.
References are checked at submit, so a hole here has to be a real one:
a job that doesn’t exist, or a scope root that isn’t steps / vars /
run / input, fails at submit rather than in the child. That includes
text that only looks like a hole - "${{ github.sha }}" copied from
elsewhere, or a chart value - which previously reached the child as
literal text and now fails. Worth knowing for a handler or schedule,
whose YAML is stored and recompiled on every delivery or tick: such a
document starts failing when it next fires, not when you deploy.
An inline definition: is the exception: it passes through unrendered, so
its own markers resolve in the child’s run rather than being filled in
from the parent’s scope.
Letting the child answer a marker
Section titled “Letting the child answer a marker”A node: target’s config: is written in the calling document, so
everything in it is filled in from the calling document’s scope. That is
usually what you want: url: ${{ steps.build.output.endpoint }} reads a
job you can see.
Sometimes the value only exists in the child. A single node called on its
own resolves its config against the input: you hand it, and to reach
that you wrap the expression in $defer:
call_api: workflow: node: kind: http config: url: { $defer: { $expr: "input.endpoint" } } # the child answers this method: ${{ vars.HTTP_METHOD }} # this document answers this input: endpoint: https://api.example.com/v1/items$defer hands its expression down one scope instead of resolving it
here. The child’s scope holds only input and run, so a deferred
expression may read those and nothing else - reaching for steps,
vars or nodes is refused, since those belong to the document you
wrote it in. If you need one of those values in the child, put it in the
job’s input: and read it from there.
Run cryo check before you push. A pipeline’s markers are checked when
the compiled graph is validated, which cryo check and cryo render do
for you; submitting YAML straight to the API skips that, and a misplaced
$defer then shows up as an unresolved object reaching the node instead
of an error naming the document.
A $defer anywhere else is refused for the same reason: nothing below a
plain node’s config, a graph: child’s input:, or a job’s own input:
would ever answer it.
Job kind: script:
Section titled “Job kind: script:”A durable polyglot script run inline as one script_run
activity of this run - the full cryo toolkit (step, sleep,
wait-signal, activity-*), with suspend-and-replay. Like
workflow: but without a separate child run: the script’s durable
boundaries are events in this run, and downstream jobs depends_on
it like any other.
analyze: depends_on: [build] outputs: verdict: verdict script: | #!/usr/bin/env bash set -eu score=$(cryo step "scan" -- bash -c 'echo 0.98') cryo sleep 30s # suspends + replays in place cryo output set verdict=ok score="$score"The body is template-rendered against the parent scope. The script’s
cryo output set values are the job’s raw output for outputs:. A
non-zero exit fails the job. requires: adds capabilities on top of
the base polyglot (e.g. pin to a build runner).
script: vs workflow: - both run a durable polyglot script.
script: runs it inline in this run (lighter, one run page); use
workflow: when you want a separate child run - for isolation, a
long-lived independent unit, or a nested cryosleep/pipeline/v1.
Templating
Section titled “Templating”When a hole is rendered, and what it can read
Section titled “When a hole is rendered, and what it can read”A value is rendered at the last moment before it is needed, and can read exactly what exists by then. That is why some fields read more than others - not a per-field rule to memorise, just how far along the run is:
| Moment | In scope | Fields |
|---|---|---|
| the event arrives, no run yet | event |
top-level if:, paths:, paths_ignore: |
| the run is created, no job has run | input, vars |
top-level concurrency.group |
| a job is about to dispatch | + steps.*, run |
job if:, env:, approval.prompt, a job’s concurrency.group, a workflow: job’s input:/config: |
| one matrix combination | + matrix |
the body, and requires: |
| every job has finished | steps.* of all of them |
document-level outputs: |
Reading something from a later row is an error naming the field, rather
than an empty value. The top-level concurrency.group cannot see
steps.* because admission decides whether any job runs at all; a job’s
own group is rendered much later and reads them.
Two fields refuse a hole outright. Step env: is quoted into the step
script before the expression resolves, so a rendered quote would break
out of quoting the compiler wrote. wait.signal and
wait_for_event.event are registered as subscriptions at submit and
waited on later, so a hole would have to render the same in both places.
${{ ... }} expressions in step run: bodies, if: conditions, and
workflow script: bodies evaluate CEL - the same
expression language the canvas and graph surfaces use. A hole is compiled
when the pipeline is submitted, so a syntax error or a malformed hole
fails at save, naming the job or step it sits in.
References are resolved then too, against the document: a scope root that
doesn’t exist (${{ jobs.build.outputs.tag }}), a job name nothing
declares, a field other than status or output, and - for a job that
declares outputs: - an output key it doesn’t list, are all rejected
before the run starts. cryo check reports the same set offline. What is
left for run time is the case the document can’t decide: an output key on
a job that declares no outputs:, since only the job itself knows what it
produced. That stays a hard error, as in Outputs.
Plain ${VAR} bash expansion is untouched; only the ${{ sequence is a
hole.
Available variables:
-
vars.<NAME>- document constants declared in the top-levelvars:block -
steps.<name>.output.<key>- outputs from completed upstream jobs -
steps.<name>.status-success/skipped/failed -
input.<field>- the run’s canonical input. For an event-triggered run that is the shaped event (input.ref,input.sha,input.repo,input.paths); for a manual run it is whatevercryo submit --input '{"sha":"…"}'passed, or{"type": "manual"}when nothing did. It is the same value a job reads from$CRYO_INPUT_FILE, so a pipeline reads it directly rather than piping throughjq, and it is never size-capped - an expression reaches into a full forge body. In scope in step bodies and jobif:.This is where a value that changes from run to run belongs.
vars:is for what does not change, andenv:never reaches an expression. -
event.<field>- in the top-level triggerif:only: the delivered event (type,ref,sha,repo,paths,payload, …)
Available functions in if::
success()- true if all upstream jobs succeededfailure()- true if any upstream failedcancelled()- true if any upstream was cancelledalways()- true (run unconditionally)
CEL brings list/map operations too - steps.scan.output.items.filter(i, i.ok), .startsWith("release/"), size(...), ternaries - so a
predicate rarely needs more than one expression.
deploy: if: ${{ success() && steps.build.output.branch.startsWith("release/") }} steps: - name: deploy run: ./deploy --tag "${{ steps.build.output.tag }}"Bare {{ ... }} is literal text - only ${{ ... }} is a CEL hole.
Outputs
Section titled “Outputs”outputs: on a job maps <output_name> to a key in the job’s raw
output. Source of the raw output:
| Job kind | Raw output source |
|---|---|
steps: |
cryo output set values |
approval: |
submission JSON |
wait: |
empty (no outputs) |
workflow: |
child workflow’s final output JSON |
script: |
script’s cryo output set values (plus exit_code/duration_ms) |
Downstream jobs reference selected outputs via
${{ steps.<this>.output.<output_name> }}. Naming a key the job’s
outputs: block doesn’t declare is rejected at submit. Where there is no
outputs: block to check against, the miss surfaces when the reference
is evaluated, and it is a hard error - fail loud, don’t render empty.
Document-level outputs: (what the run returns)
Section titled “Document-level outputs: (what the run returns)”A job’s outputs: moves a value between jobs inside the run. The
document’s own outputs: block, written at the top level beside jobs:,
is what the whole run hands back to whoever ran it: a workflow: job
running this document as a child, or a graph’s workflow node running
the pipeline a repo holds.
outputs: sha: ${{ steps.build.output.sha }} image: "registry.example.com/app:${{ steps.build.output.sha }}" report: passed: ${{ steps.test.output.passed }}
jobs: build: outputs: sha: sha steps: - name: build run: cryo output set sha="$(git rev-parse HEAD)" test: depends_on: [build] outputs: passed: passed steps: - name: test run: ./test.sh && cryo output set passed=trueValues take ${{ … }} holes over the same steps.<job>.output.<key>
scope a job’s if: reads, at any depth - nest them in maps and lists and
the shape comes out the way you wrote it. A hole that is the whole value
keeps its JSON type (passed arrives as a boolean); a hole with text
around it can only produce a string.
The two blocks compose. The document reads what the jobs declare, so a
job has to publish a key in its own outputs: before the document can
hand it on, and cryo check names the job when it doesn’t.
A parent picks the names up the way it picks up any child output - with
the outputs: mapping on the job that spawned the child:
release: outputs: image: image # the child's declared `image` workflow: type: cryosleep/pipeline/v1 script: …The run’s output carries every job’s status under nodes, and the
declared outputs sit beside that map rather than under a key of their
own. A caller therefore reads one as an ordinary output of the child
(nodes.<child>.output.<name> from a graph), and the arrangement
reserves a single name: an output called nodes is refused at submit.
Only a successful run produces them - a failed run carries no output at all, so a caller either gets every declared output or gets a failure. An output that can’t be computed fails the run and the error names the output, rather than completing with a hole the caller trips over later.
Dependencies
Section titled “Dependencies”depends_on: [a, b] orders this job after both a and b. A job with
no if: carries an implicit success() gate, so it is skipped when any
dependency failed. A cleanup or notify job opts out of that gate with
if: always() or if: failure().
A skipped job (via if: false) still satisfies its dependents’
depends_on. It reports steps.<name>.status == "skipped" and exposes
no outputs, so gate on that status rather than reading one of its output
keys - a reference to a key it never produced is a hard error, not an
empty string.
matrix: (one job per combination)
Section titled “matrix: (one job per combination)”A matrix: runs a job’s body once per combination of the values you
declare, with the combination readable as ${{ matrix.<name> }}:
jobs: test: matrix: values: os: [ubuntu, macos] rust: ["1.80", "1.81"] max_parallel: 2 steps: - name: test run: cargo +${{ matrix.rust }} test --target ${{ matrix.os }} publish: depends_on: [test] steps: - name: publish run: ./publish.shThat is four runs of the body: (ubuntu, 1.80), (ubuntu, 1.81),
(macos, 1.80), (macos, 1.81). Dimensions keep document order and the
last one varies fastest, so the list reads the way you wrote it.
Values are JSON, not just strings. A dimension whose values are objects carries several related settings together, so one dimension describes a target rather than three that have to be kept in sync:
matrix: values: target: - { os: ubuntu, runner: linux, flags: "--release" } - { os: macos, runner: darwin, flags: "" } steps: - name: build run: ./build.sh ${{ matrix.target.os }} ${{ matrix.target.flags }}exclude: subtracts from the product. Each entry is a partial
combination: every dimension it names has to match, and a dimension it
omits is a wildcard.
matrix: values: os: [ubuntu, macos, windows] rust: ["1.80", "1.81"] exclude: - { os: macos, rust: "1.80" } # that one pairing - { os: windows } # every windows rowSix combinations become three: (ubuntu, 1.80), (ubuntu, 1.81),
(macos, 1.81). The alternative is a guard inside the body, which still
claims an agent to decide it has nothing to do.
An entry matches by value, so an object-valued dimension is named in
full (- { target: { os: macos, runner: darwin, flags: "" } }).
Dimension names are checked at submit: exclude: [{ platform: ubuntu }]
on a matrix with no platform fails, listing the dimensions it does
have. Values are not checked, so a misspelled one ({ os: windwos })
drops nothing and reports nothing; it shows up as a combination you
thought you had removed. Excluding every combination is refused, since
the job would then never run.
It stays one job. depends_on: [test] is one edge and waits for
every combination; there is no test (ubuntu, 1.80) to name. That is
deliberate - a downstream job can’t know which combinations exist, so
making it name them would break every time the matrix changed. The job’s
output is the list of per-combination outputs, in combination order.
max_parallel:caps how many run at once. Absent means no ceiling beyond the agents free to claim them.rate: { limit, period }caps how many start per period -limit: 10, period: 1mdispatches ten combinations, waits out the rest of the minute, dispatches the next ten. Use it when the thing being called counts requests per minute;max_parallel:alone doesn’t bound that, since four at a time is still hundreds an hour if each is quick. The wait between windows is a durable timer, so a sweep paced over an hour holds no agent while it waits.fail_fast:defaults to true - the first failing combination fails the job.falseruns all of them and reports the failures together, which is what you want when the point is to see the whole grid.retry:,requires:andtimeout:apply per combination, not to the sweep:attempts: 3retries the one combination that failed.
requires: can read the combination, which is how one job spans
platforms instead of being copy-pasted per capability:
build: matrix: values: target: - { cap: linux-amd64, triple: x86_64-unknown-linux-gnu } - { cap: linux-arm64, triple: aarch64-unknown-linux-gnu } - { cap: darwin-arm64, triple: aarch64-apple-darwin } requires: ["${{ matrix.target.cap }}"] steps: - name: build run: cargo build --release --target ${{ matrix.target.triple }}Each combination claims an agent advertising its own capability. Mixing
literal and computed entries is fine (requires: ["${{ matrix.target.cap }}", nix]).
A hole in requires: on a job with no matrix: is rejected at
submit. There would be nothing for it to read, and since capabilities
match literally, an unresolved one would leave the job sitting unclaimed
forever with nothing logged.
matrix: applies to the activity-backed kinds (steps: and script:).
The parking kinds (approval:, wait:, wait_for_event:, workflow:)
run in the workflow process and have nothing to spread across items, so a
matrix on one is refused at compile time rather than at run time.
An empty dimension is also refused - it would make the whole product empty and the job would silently run zero times.
Under the hood a matrix job compiles to a single fan_out node, the same
primitive a graph uses to spread work over a list. cryo check shows the
compiled form.
Dynamic pipelines
Section titled “Dynamic pipelines”matrix: covers the case where you know the combinations when you write
the document. To decide work at runtime - fan out over something you just
computed,
or let a step generate the jobs to run - have a script: job compute a
graph definition and run it as an awaited child with cryo call graph:
plan: script: | #!/usr/bin/env bash set -eu # Compute the work however you like, emit a graph definition, run it. ./generate-pipeline.py > pipeline.json cryo call graph --definition pipeline.json
deploy: depends_on: [plan] steps: - { name: deploy, run: ./deploy.sh }The generated graph runs as its own durable child run. The plan job
parks until that run finishes, cryo call graph prints its output, and
deploy (which depends_on: [plan]) runs after the whole generated
pipeline is done. Pass --definition - to read the definition from stdin
instead of a file.
What the script emits is a cryosleep-graph/v1 definition: a JSON
document with a version, a list of nodes, and optional edges. Each
node is { "id", "type", "config" }; unknown fields are rejected.
{ "version": "cryosleep-graph/v1", "nodes": [ { "id": "build", "type": "shell", "config": { "run": "cryo output set tag=v1.2.3" } }, { "id": "ship", "type": "shell", "config": { "run": { "$template": "echo shipping {{ nodes.build.output.tag }}" } } } ], "edges": [ { "from": "build", "to": "ship" } ]}Values cross between generated nodes the same way they cross between
jobs, with graph spelling: cryo output set stages a key and the next
node reads nodes.<id>.output.<key> from a $template or $expr.
ship logs shipping v1.2.3. There is no outputs: hop here - a
node’s staged keys are its output.
--definition takes a graph, in JSON or YAML. A generated pipeline is
a different document and is rejected here - cryo call graph runs
graphs. Run cryo check <file> to validate a definition before wiring it
into a run.
Composition covers the node kinds and the rest of the
format.
The child keeps its own run id: it has its own run page, carrying a
breadcrumb back to this run, and cryo runs ls trails parent:<run-id>
on it. Because it is a complete, self-contained definition, it is
validated when it starts and reruns on its own, and the parent’s job set
stays fixed.
A run’s job set is fixed when the run starts. Splicing jobs into a
running pipeline is not supported; the computed child graph above is how
work decided at run time gets run. That is also why matrix.values takes
a literal list and refuses a ${{ … }} hole: the fan-out would have to
exist before the job computing it has run.
examples/computed-graph is the smallest complete version of the
alternative.
Anti-patterns
Section titled “Anti-patterns”- Don’t use
depends_on:to enforce execution-on-the-same-agent semantics across separate jobs. Each job is a separate activity and may land on different agents. If you need a shared filesystem or state across operations, put them in the same shell job’ssteps:. - Don’t call durable primitives (
cryo step/sleep/wait-signal/wait-event/activity-*/spawn/call/lock) from inside a YAML shell step. The agent rejects them with an error naming the alternatives: a shell step is atomic, so it can’t suspend and replay, and a durable call would lose its guarantees. Use await:,approval:,script:, orworkflow:job for durable behaviour. Calls that never suspend do work in a shell step:cryo annotate,cryo state,cryo emit,cryo output set,cryo artifact,cryo status. Atomic and durable activities is the reason behind the split. - Don’t loop on
cryo sleepfor cron-shaped work - use Schedules instead.