Skip to content
zoryn/ maintainer-assistant

zoryn flow

zoryn flow runs a flow — a user-defined sequence of steps that turns a routine package update into a single command. A flow can update and build the package, run your own checks over ssh or inside a devenv, submit the result to gyle, and — when a step fails — run a recovery flow (up to and including an AI agent that reads the build log) and retry.

Nothing in a flow ever prompts: it is meant to run from cron, a CI job or a terminal you are not watching.

Experimental

zoryn flow is experimental. Step types, config keys and state format may change in future releases.

Synopsis

zoryn flow [NAME] [--continue] [--restart] [--abort] [--dry-run] [-C DIR] [-d] [-v]
zoryn flow run [NAME] [--continue] [--restart] [--abort] [--dry-run] [-C DIR] [-d] [-v]
zoryn flow list [-C DIR]
zoryn flow show NAME [-C DIR]

zoryn flow NAME and zoryn flow run NAME are the same thing — the plain form is rewritten to run before cmdliner sees it. Only a flow literally named run, list, show or help needs the explicit zoryn flow run <name> form.

Run the command from inside a gear repository; zoryn switches to the repository root first, so config layers, .gear/version-up, {pkg} and the state file all refer to the same package regardless of the subdirectory you started in.

zoryn flow run

Runs the flow. Without NAME the flow is chosen by [flow] name in .gear/version-up, and failing that by default in the [flows] table of the machine config; with --continue and no NAME the saved state decides.

Options

  • NAME — flow to run. Without it the flow comes from [flow] name in .gear/version-up, else from [flows] default.
  • --continue — resume the interrupted flow from the step that failed.
  • --restart — discard the saved state and run the flow from its first step.
  • --abort — discard the saved state of an interrupted flow.
  • --dry-run — print the resolved steps and exit without running anything.
  • -C DIR — change to directory DIR before running.
  • -d, --debug — enable debug output (also traces the commands zoryn spawns).
  • -v, --verbose — show build output on screen for build steps, as zoryn build -v does (the initial build inside an up step always goes to its log file).

--continue, --restart and --abort are mutually exclusive, and --continue cannot be combined with --dry-run.

zoryn flow                  # run the selected flow
zoryn flow update           # run the flow named 'update'
zoryn flow update --dry-run # print the resolved steps only
zoryn flow --continue       # resume after fixing a failed step
zoryn flow --abort          # discard the saved state

zoryn flow list

Lists the flows visible from the current package, one per line, each followed by the config file that defines it. Where the same name is defined in two layers, only the winning definition is listed.

$ zoryn flow list
update
  /home/user/.config/zoryn/projects.d/curl.toml
fix-build
  /home/user/.zoryn

zoryn flow show

Prints the steps of NAME in the same numbered form --dry-run uses, with each step's if, on-failure and non-default max-retries below it. Variables produced by later steps are not known yet, so a line still containing {new_version} or {task} is printed verbatim.

$ zoryn flow show update
Flow update
  1. up
     on-failure: fix-build
     max-retries: 3
  2. devenv: make smoke
  3. ssh stand1: run-checks {pkg} {new_version}
  4. submit -B sisyphus --run

Where flows are defined

Flows live in the machine-local configuration only, in two layers:

  1. ~/.config/zoryn/projects.d/<project>.toml — per package, wins;
  2. ~/.zoryn — global.

A package repository can not define steps. Steps carry shell commands and ssh hosts, so a cloned repository must never be able to inject them — the most a repository may do is select a flow by name:

# .gear/version-up
[flow]
name = "kernel-update"

If the selected name is not defined in your own config, the run fails with unknown flow '<name>'; nothing from the repository is executed.

Configuration format

Each flow is a [flows.<name>] table with a steps array of inline tables. The [flows] table itself holds only default, the flow used when nothing else selects one.

[flows]
default = "update"

[flows.update]
steps = [
  { run = "up", on-failure = "fix-build", max-retries = 3 },
  { run = "devenv", cmd = "make smoke" },
  { run = "ssh", host = "stand1", cmd = "run-checks {pkg} {new_version}" },
  { run = "submit", branch = "sisyphus", task-run = true },
]

[flows.fix-build]
steps = [ { run = "agent" } ]

[flows.batch-update]
steps = [
  { run = "batch", config = "qt6.toml" },
  { run = "bash", cmd = "notify-send 'batch {pkg} done'" },
]

# multi-repo submit: one submit creates a task per branch; {tasks} addresses them all
[flows.kernel]
steps = [
  { run = "submit", branch = "sisyphus,p11", task-run = false },
  { run = "zoryn", args = ["task", "add", "{tasks}", "rebuild", "--dependent-on", "kernel-image-for-vm"] },
  { run = "zoryn", args = ["task", "approve", "{tasks}", "all", "-m", "sign"] },
  { run = "zoryn", args = ["task", "run", "{tasks}", "--commit"] },
]

Step types

runKeysWhat the step does
upbuild options + no-buildthe full zoryn up update pipeline (detectup-hooks) followed by a build of the new version — or, with no-build = true, the update alone. Publishes {old_version} and {new_version}
buildbuild optionsa zoryn build
submitsubmit optionszoryn submit. The commit message is never opened in an editor, and a tag conflict cancels the step instead of asking. Publishes {task}
task-addtask (default {task}), args, dependent-onzoryn task add: adds a subtask to taskargs is the action tail (packages, rebuild, a branch), dependent-on maps to --dependent-on. All values are templated, so the default acts on the task the submit step created
task-approvetask (default {task}), subtask (default all), messagezoryn task approve: approves subtask (a number, package, pkg.git=tag or all) of task, with message as the -m comment
task-runtask (default {task}), commit, messagezoryn task run: starts the build of task; commit = true adds --commit, message the -m comment
batchconfig (required)zoryn task batch <config> --no-edit-commit; when the previous attempt left batch state behind, the retry adds --continue so it resumes that batch instead of being refused. The child never asks anything: its stdin is /dev/null, so the resume takes the default answer (do not retry the packages that failed inside the batch)
bashcmd (required)runs cmd through the system shell (/bin/sh -c) in the package directory
sshhost, cmd (both required)runs cmd on host over the configured {ssh} command; both arguments are shell-quoted
devenvcmd (required), profilezoryn devenv [--profile <profile>] -- bash -lc <cmd> — the command runs inside the dev environment, so it does have bash there
zorynargs (required, array of strings)runs zoryn with those arguments verbatim, e.g. args = ["check", "spec"]
agentcli, promptopens a devenv with the agent's feature enabled and runs the AI CLI non-interactively on the newest .gear/build.*.log (see AI-agent repair). Off unless [agent] flow_steps = true

An unknown run value, a missing required key, a steps value that is not an array of inline tables, or a missing steps array is a configuration error naming the flow (and, where it applies, the step number).

Common step keys

KeyTypeMeaning
namestringlabel printed after the step in show/--dry-run, and the step's ref in qualified flags (--<name>.<key>) — give steps of the same type distinct names to address them individually
ifstringshell condition; the step runs only when the command exits 0. A skipped step is not a failure
on-failurestringname of a recovery flow to run when the step fails, before retrying it
max-retriesinteger, default 1how many recovery-and-retry attempts the step gets. Has no effect without on-failure

if conditions are executed by the same shell as a bash step, so {git}-style command templates and flow variables both work: if = "test -f .gear/{pkg}.spec".

Step options

The up, build, submit, task-* and agent steps take typed option keys that map to the corresponding command's flags. Every option is optional; when unset, the step behaves exactly like the bare command with that flag absent — including reading your config. For example, an up/build step with no parallel key builds in parallel when [builders] parallel = "on", just like zoryn up; a submit step with no commit/task-run publishes and runs according to your [submit] config.

up and build (the build phase; no-build is up-only):

KeyTypeEffect
builderstringbuild on the named builder (else the configured/default builder)
archstringtarget architecture filter
branchstringbuild target branch
rebuildbooluse hsh-rebuild
skip-checkboolskip checks — equivalent to a bare zoryn build --skip-check (rpmbuild,all)
parallelboolforce parallel (true) or sequential (false) multi-builder builds; unset follows [builders] parallel
no-python-auto-depsbooldisable the Python auto-deps update-and-retry
no-buildboolup only: update (version bump, spec, gear tags, up-hooks) and stop, without building — the step still publishes {old_version}/{new_version}, so a later build step does the build. On a build step the key is a config error. Its CLI negation is --build, not --no-no-build

submit:

KeyTypeEffect
branchstringtarget repository (-B); unset lets the pipeline resolve it
task-runbooltrue starts the task (--run), false forces --no-run; unset follows [submit] run
commitbooltrue forces a real submit; unset follows [submit] (test-only vs publish)
test-onlybooltrue forces a test-only task
no-depsboolsubmit without dependencies
skip-checkboolskip the pre-submit spec check
allow-overwrite-tagboolallow overwriting an existing tag
messagestringcommit/tag message (no editor)
withstringadd a package to the task (--with)
replacestringreplace a subtask in an existing task (--replace)

Setting both commit and test-only is a conflict and fails the step, exactly as zoryn submit rejects it.

task-add:

KeyTypeEffect
taskstringtask to add to; defaults to {task}, the task the submit step created
argsarray of stringsaction tail passed verbatim: packages, rebuild, a branch
dependent-onstring--dependent-on PKG

task-approve:

KeyTypeEffect
taskstringtask to approve; defaults to {task}
subtaskstringa number, package, pkg.git=tag or all; defaults to all
messagestring-m comment

task-run:

KeyTypeEffect
taskstringtask to run; defaults to {task}
commitbool--commit — commit the task into the repository on success
messagestring-m comment

agent:

KeyTypeEffect
clistringthe AI CLI to run (claude, codex or opencode); defaults to [agent] cli
promptstringreplaces the built-in repair prompt; templated, and a step with its own prompt no longer requires a build log to exist

Options as flags (implicit by name)

Every option key of the selected flow's steps is also a command-line flag on zoryn flow <NAME>, so you can override a step's config value for one run without editing the flow:

  • a bool key k--k (sets it true) or --no-k (sets it false);
  • a value key k--k VALUE or --k=VALUE;
  • branch also answers to zoryn's usual short form -B (-B sisyphus,p11).

A CLI value overrides the step's config value for the step(s) that declare it; unset on both CLI and config still means "command default". --dry-run prints each step with its effective options, so you can see what will run.

If a key is declared by only one step, the bare --k is enough. If more than one step declares it (for instance both up and submit accept skip-check), the bare form is ambiguous and rejected; qualify it as --<step>.<key>, where <step> is the step's name if set, otherwise its run type (up, submit, …). The qualified form is always accepted.

An unknown flag lists the options the flow accepts; an ambiguous one lists the concrete qualified forms. Bash TAB completion offers the selected flow's flags and completes branch names after -B/--branch.

zoryn flow release --commit           # override the submit step's commit
zoryn flow release --no-commit        # force a test-only submit for this run
zoryn flow update --submit.skip-check # qualify a key shared by two steps
zoryn flow update --builder arm-01    # send the up/build to a specific builder

Substitutions

The tokens {pkg}, {old_version}, {new_version}, {tasks} and {task} are replaced in a step's cmd, host, args elements, prompt and if value:

TokenValueAvailable from
{pkg}package directory name (the git repository root)the first step
{old_version}version before the updateafter a successful up step
{new_version}version after the updateafter a successful up step
{tasks}all ids of the gyle tasks just created, comma-separated in submission order (e.g. 123,456 for a multi-repo submit)after a successful submit step
{task}the first created task id (single-repo back-compat)after a successful submit step

Prefer {tasks} — it works for one repo (just 123) or many, and zoryn task run/add/approve all accept the comma-separated list. Use {task} only when you deliberately want the first id alone.

Using a token whose value no earlier step has produced fails the step with flow variable '<name>' is not available yet (produced by a later step) — it is a config mistake, not a runtime accident.

Every other {...} token is left alone and reaches the usual command templates{git}, {ssh} and friends keep working inside cmd values.

A substituted value crosses into /bin/sh, and some values come from the package repository ({old_version} is the raw Version: field). To keep a hostile repository from smuggling shell syntax through them, a value is only substituted when it consists solely of the safe set A-Za-z0-9.+~_,- (which covers any real package name, version, task id and the comma-joined {tasks} list); anything else fails the step with flow variable '<name>' has an unsafe value … rather than reaching the shell.

The other keys (branch, config, profile, cli, name) are used as written; they are not templated.

Branching, retries and recovery

A failing step is not automatically fatal. When it has an on-failure flow and attempts are left:

  1. the recovery flow is looked up in the same layers and run to completion;
  2. the failed step is retried;
  3. max-retries counts these attempts (default 1, i.e. one recovery plus one retry).

The recovery flow is a normal flow — it can itself have steps with on-failure. Nesting is capped at depth 5, so a flow that recovers into itself and always fails still terminates with a failure instead of looping. A recovery flow's own progress lives in memory only and is never written to the state file.

If the recovery flow fails, or its name is not defined, the original step fails with both messages joined (build failed; recovery flow 'fix-build' also failed: …).

The up step and a pending build

up bumps and commits the version before it builds. Once the commit lands, a naive retry would find the package up to date and stop the flow as "nothing to do", never building the committed tree and never reaching submit. zoryn closes that in both directions. The bumped version and a pending-build marker are written to the state file before the build starts, so even a hard kill during the build (Ctrl-C, kill, OOM, reboot) leaves resumable state. And if the build merely fails, the same marker is recorded with the versions the attempt computed. Either way the retry — in the same process after a recovery flow, or a later zoryn flow --continue, even from a fresh process — rebuilds the version that is already there, publishes {old_version} and {new_version} as a successful up would, and the flow carries on to the following steps.

An up step that finds no new version is not a failure: it stops the flow successfully (exit 0). This is the normal outcome of a nightly cron run.

AI-agent repair

An agent step opens a devenv with [agent] cli (default claude, also codex or opencode) enabled as a devenv feature, and runs that CLI non-interactively — claude -p, codex exec, opencode run — with a prompt naming the newest .gear/build.*.log and pointing the CLI at the build-repair workflow of the bundled zoryn Agent Skill (references/repair.md: read the log, fix the packaging, verify with gear-rpm inside the devenv, leave the changes uncommitted for the flow's next steps). The devenv bind-mounts the bundled skill directory read-only at its host path, so the pointer installed by zoryn agent skill install resolves inside it. If there is no build log, the step fails; an unsupported cli fails the step too.

Both halves can be set per step: the cli key overrides the [agent] cli machine value, and the prompt key replaces the built-in repair prompt entirely — { run = "agent", prompt = "run the tests of {pkg} and fix what fails, then exit" }. A step prompt is templated like a cmd and stands on its own, so it does not require a build log to exist; making the CLI exit when done stays the prompt's responsibility, as it is for the built-in one.

The agent runs with your real credentials, mounted writable

The devenv the agent runs in mounts your host ~/.claude and ~/.claude.json (and the codex/opencode equivalents) read-write — the CLI needs them to authenticate. The "change only this repository" rule lives in the skill only; nothing in the sandbox enforces it, and a prompt-injected agent (build logs are attacker-influenced text) could write to those files, e.g. installing hooks that run on your next session. Agent steps are therefore off by default: enable them with [agent] flow_steps = true in ~/.zoryn, and only for flows and repositories you trust. Without it, an agent step fails with a message explaining this.

Combined with on-failure and max-retries this gives the "build broke, let the agent try, build again" loop:

[flows.update]
steps = [ { run = "up", on-failure = "fix-build", max-retries = 3 } ]

[flows.fix-build]
steps = [ { run = "agent" } ]

[agent]
cli = "claude"
flow_steps = true

State and resuming

A flow run keeps its position in ~/.local/state/zoryn/flow-<project>-state.json ($XDG_STATE_HOME is honoured). The state holds the flow name, the next step, the variables collected so far and the used attempts of the current step; it is written after every completed or skipped step and cleared when the flow finishes or stops cleanly.

  • A failed step leaves the state in place and prints how to resume. Fix the problem, then zoryn flow --continue retries exactly that step with the variables it already had.
  • Starting a flow while an unfinished one is saved is refused — choose --continue, --restart or --abort.
  • --continue and --restart without NAME act on whatever the state names, even if the config would now select a different flow. Given a NAME that does not match the saved flow, both are refused.
  • The state file is keyed by the package directory name, so two checkouts of one package (e.g. a sisyphus and a p11 clone both in a directory called foo) would share it. The saved state records its checkout path, and a --continue/--restart from the other checkout is refused rather than resuming the wrong tree.
  • --abort only needs the file to exist, so it also rescues a state file too damaged to load; a corrupt-state error on any command names --abort as the way out.
  • --dry-run prints the plan before any of this: an interrupted flow neither blocks it nor is discarded by --restart.

Because a flow with an up or build step depends on a working builder, the builders are resolved and their mountpoints validated before the first step runs — a misconfigured builder fails the flow immediately instead of halfway through.

Exit codes

CodeMeaning
0the flow finished, or an up step found no new version
1a step failed (state saved), or the flow could not be started: unknown flow, no flow selected, unparsable config, incompatible flags, no usable builder