Jobs and steps
This is the part of Walis worth understanding properly. Everything else — triggers, runners, notifications — is plumbing around this model.
Three nouns
| What it is | Where it lives | |
|---|---|---|
| Job | Configuration: a script, where to run it, when to run it, who may run it. | A row in Jobs. |
| Build | One execution of a job, with a number, a status and a log. | A row in Builds. |
| Step | One shell inside that build, with its own status, output, exit code and duration. | Rows in BuildSteps. |
The important half is that a job stores no steps. Steps are derived when a build starts:
- a job written as a script becomes one step
- a job built from an action flow becomes one step per action
So editing a job never rewrites history, and adding the whole step model to Walis required no migration of past builds — there was nothing on the job to migrate.
The script is the interface
Walis executes shell. Nothing else. Flow mode is a script editor, not a runtime: when you save a job, the flow is compiled into a plain shell script and stored in Jobs.Command.
This has consequences worth stating out loud:
- You can read what will run. Switch to script mode and the whole thing is there.
- Runners need no knowledge of actions. A remote runner receives shell. It has never heard of an action, a plugin, or a stage.
- Plugins return text, not behaviour. An action plugin produces a shell snippet, which you can inspect before installing it.
- The compiler is lossy in one direction. Flow → script keeps the compiled output. Script → flow restores the steps and discards manual edits. Once you have hand-tuned a script, stay in script mode.
Every step is a fresh shell
Steps share the working directory. They do not share environment variables. To pass a value on, append to the file Walis points at:
echo "BUILT_VERSION=1.2.3" >> "$WALIS_ENV_FILE"The next step reads it as $BUILT_VERSION. This matches how other CI systems behave, and it keeps a step from silently depending on the shell state of the one before it — the failure mode where reordering two steps breaks a build for reasons nobody can see.
The build's own environment is an allow-list, not a filtered copy of the server's: a build inherits PATH, USER, SHELL, locale, temp directories and the TLS certificate variables, and nothing else. The controller's connection strings and service secrets are not in there to be leaked.
Two ways to pass a value on
| What it is | Use it for | |
|---|---|---|
$WALIS_ENV_FILE | Append KEY=VALUE. Later steps read it as an environment variable. | Untyped intermediate values. Not recorded anywhere. |
$WALIS_OUTPUT_FILE | Write name=value lines, or one JSON object. This is the step's output. | Values worth keeping: they are stored on the build, shown in the log's step panel, and can be referenced by name. |
An output is referenced in a later step's parameter as ${{ steps.ID.NAME }}, where the identifier is what you set in that step's settings. When the job is saved, the reference compiles to a plain shell variable, and the value is injected as an environment variable at run time — the value is never spliced into the script text, so there is nothing to inject through. A remote runner needs no knowledge of the expression syntax; it runs the same compiled script.
You can only reference a step that is upstream of this one on the dependency chain, transitively. A step running alongside yours, or after it, is not available — which is the graph telling you the value may not exist yet. Limits: 100 outputs, 8 KB per value, 64 KB per output file.
The built-in Set output action is a shortcut for writing that file. In script mode, read the variable directly as $WALIS_STEP_<identifier>_<name>.
Conditions on data
Beyond run when, a step can carry a run condition — an expression evaluated at run time against earlier steps' outputs and the environment:
steps.check.changed == 'true' && env.BRANCH != 'main'
matches(steps.api.status, '^2..$') || empty(steps.api.body)A false condition marks the step skipped, not failed, so downstream steps still judge themselves by their own rules. Two branches with mutually exclusive conditions, both depending on the same step, is how you write an if/else.
The syntax is deliberately small: string literals, steps.<id>.<name>, env.<name>, ==, !=, &&, ||, !, parentheses, and the functions contains, startsWith, endsWith, matches and empty. No arithmetic, no assignment. Syntax is checked when the job is saved, and references are checked to point upstream; a syntax error at run time fails that step.
Both the run condition and run when have to hold for a step to run.
What each step carries
Every step in a flow has five switches, independent of which action it uses:
| Switch | Effect |
|---|---|
| Enable this step | Off means it is skipped without failing the build. A step that depended on it depends on its upstream instead. |
| Continue on failure | A non-zero exit does not fail the build. |
| Depends on | Which steps must finish first. This is what makes the flow a graph. |
| Identifier | A short name (lowercase, unique in the flow) so later steps can reference this one's outputs. |
| Run when | Only if everything before succeeded (default), Always, or Only after a failure — judged against this step's dependencies, not the whole build. |
| Run condition (expression) | Evaluated at run time against earlier steps' outputs and environment. False means skipped, not failed. |
| Retries | 0–10 attempts after the first. |
Run when is what Jenkins expresses with post { always / failure }, and it changes what a failure means:
A failed step no longer aborts the build. Later steps set to always or only after a failure still run — cleanup, teardown, a notification — while steps set to success are marked
Skipped. The build's result is still Failed.
Only the job timeout stops a build outright. Everything else runs to the end of the plan.
Retries give the step a fresh shell each attempt; earlier output is kept above the current attempt so you can see it was the third try that passed. A retry never extends the job timeout — the attempt gets whatever time is left.
Script mode has no per-step switches, because a single compiled script has nowhere to put them. The Jenkins importer notes this when it hits a post block it cannot express.
The flow is a graph, not a list
A step declares what it depends on. Everything else follows from that:
- A step starts once every step it depends on has finished.
- Steps with no dependency are entry points and start together.
- Branches that do not depend on each other run at the same time — up to 8 steps at once per build.
So parallelism is not something you switch on; it is what happens when two steps do not depend on each other. Flow mode has a List and a Graph view: in the graph, drag from a node's right-hand dot to another node to add a dependency, click an edge to remove it. Layout is automatic and not saved — moving nodes around changes nothing.
Jobs written before this existed keep working. A flow with no dependencies recorded is derived from the old order-and-parallel arrangement, with exactly the same meaning. The first time you touch a dependency — tick a box or drag an edge — the editor converts the whole flow to an explicit graph and sends it with the next save. There is no conversion step to run and nothing to confirm.
What a failure does to a graph
Run when is judged against a step's own dependencies, not the build as a whole:
- Only if everything before succeeded — every dependency succeeded. A step that failed but had continue on failure set counts as succeeded.
- Only after a failure — something upstream failed, including a step skipped because of an earlier failure.
- Always — runs regardless.
A failed branch therefore does not touch branches that do not depend on it. Where branches rejoin, the join is marked skipped and passes the failure downstream — which is why a cleanup or notification step placed after the join still runs.
Each step in a group running at the same time gets its own $WALIS_ENV_FILE; a shared one would let them overwrite each other. The aggregated log does not update live while several steps run at once — per-step output still streams normally.
Build parameters
A job can declare up to 50 parameters — string, text, choice, boolean or password. A manual run opens a form; schedules and push triggers use the defaults. trigger_build over MCP accepts them too.
Values arrive as environment variables, so the script reads them as $RELEASE_TAG. Two rules keep that from becoming an injection hole:
- Names cannot start with
WALIS_. That prefix belongs to the system. - Unknown names are rejected, not ignored. Passing a parameter the job never declared fails the trigger, so a parameter cannot be used to set arbitrary environment variables. Choice values must be one of the declared options.
password parameters are handled separately end to end: the value is stored in a column the API never serialises, added to the log masking list, and shown as ******** in the UI. Non-secret values are visible on the build, which is usually what you want when reading old builds.
One build at a time
Turning off allow concurrent builds is Jenkins' disableConcurrentBuilds(). A second build is not rejected — it waits in the queue until the first finishes.
The gate is enforced where it has to be: in the same transaction that moves a build from Queued to Running, so two nodes racing for the same job cannot both win. A local runner that loses the race re-queues the build a few seconds later rather than dropping it.
Downstream jobs
A job can list up to 20 jobs to trigger when it finishes — on success, on failure, or always. Targets are named, not referenced by identifier, so an exported job still resolves on another machine.
The downstream build receives WALIS_UPSTREAM_JOB, WALIS_UPSTREAM_BUILD and WALIS_UPSTREAM_STATUS, so a script can tell who woke it up.
Two limits are deliberate:
- Chain depth stops at 10. A → B → A does not run forever; it runs ten deep and stops.
- The upstream job's run role gates the trigger. An automatic trigger has no interactive user behind it, so Walis checks the downstream job against the upstream job's run role. An operator-runnable job cannot be turned into a side door onto an administrator-only deployment.
Unlike Jenkins' build job:, Walis does not wait for the downstream result. The upstream build is finished; the downstream one is its own build with its own number.
Matrix builds
Give a job a matrix and one trigger produces one build per combination:
{ "NODE": ["20", "22"], "OS": ["mac", "linux"] }Each build gets the values as environment variables and carries a label such as NODE=20, OS=mac. Capped at 50 combinations, because four axes of five values is 625 builds and rarely what anyone meant.
Caching
List paths to keep between builds, relative to the working directory. On success they are stored and restored on the next run. The order matters: Walis cleans the workspace first, then restores — the other way round would wipe what it just restored. Absolute paths and .. are ignored.
Cache and artifact collection run in the API process rather than inside the build, so neither follows symbolic links. Otherwise ln -s <data directory> out would turn artifact collection into a way to copy the database out.
Timeouts
The job timeout is a budget for the whole build, not per step. Each step receives what is left; a step that starts with no time left is failed with a message saying so, rather than being started and killed mid-write.
Import from Jenkins
If you already have Jenkinsfiles, do not retype them — the importer and its mapping table cover what translates and what does not.