Skip to main content

Stages, jobs, and steps

Loom workflows are organized into three levels: stages, jobs, and steps. Together, they control execution order, environment isolation, and the structure of runtime diagnostics — so when something fails, you can pinpoint the exact command without scanning aggregated logs.

Why this hierarchy matters

Each level has a different role:

  • Stages define execution phases — build before test, test before deploy.
  • Jobs are the unit of scheduling, isolation, and provider selection.
  • Steps are individual commands with their own event streams, so you can jump straight to the failing command's output.

The same hierarchy organizes runtime log paths, so manifests can point to the failed script entry's output.

The three levels

LevelWhat it isYAML locationRequired
StageAn execution phase that groups jobsstages: [build, test, deploy] at rootYes (at least one)
JobA scheduled unit of work with its own environmentTop-level key matching ^[a-z][a-z0-9_-]{0,63}$Yes (at least one)
StepA single command inside a job's script:Each entry in a job's script: listYes (at least one per job)

How they nest

workflow.yml
├── stages: [build, test] ← execution phases

├── compile: ← job (build stage)
│ ├── stage: build
│ ├── target: linux
│ └── script:
│ ├── step 1: make deps ← step
│ └── step 2: make build ← step

├── unit: ← job (test stage)
│ ├── stage: test
│ ├── target: linux
│ └── script:
│ └── step 1: go test ./... ← step

└── lint: ← job (test stage)
├── stage: test
├── target: linux
└── script:
└── step 1: golangci-lint run ← step

Corresponding YAML

version: v1
stages: [build, test]

compile:
stage: build
target: linux
script:
- make deps
- make build

unit:
stage: test
target: linux
script:
- go test ./...

lint:
stage: test
target: linux
script:
- golangci-lint run

Execution ordering

Loom compiles stage barriers, explicit needs, and artifact inputs into dependency edges. The local executor starts dependency-ready jobs concurrently within its configured limit. A failed job blocks its dependents while independent jobs continue.

Stage ordering

The stages: list supplies default barriers. A job that omits needs waits for every executable job in every earlier declared stage.

In the example above: compile (build stage) finishes before both unit and lint (test stage) start.

Job ordering within a stage

Jobs in the same stage have no implicit dependencies. Dependency-ready jobs may run concurrently; Loom uses job-name order when selecting among ready jobs.

In the example above, lint and unit can run concurrently after compile finishes.

Explicit dependencies with needs

Use needs to declare that a job depends on specific other jobs:

integration:
stage: test
needs: [compile]
target: linux
script:
- go test -tags=integration ./...

An explicit list replaces the job's stage barriers, so integration waits for only compile. An explicit empty list (needs: []) adds no stage dependency and permits immediate readiness. Artifact inputs remain additional prerequisites in both cases. This is useful for:

  • Dependencies between jobs in the same stage.
  • Selecting only the earlier jobs that a later-stage job requires.
  • Bypassing the default stage barrier with needs: [].

Compilation rejects missing targets, self-dependencies, and cycles at the offending needs entry.

Step ordering

Steps within a job run sequentially in the order listed. If any step exits with a non-zero code, the job fails immediately and remaining steps are skipped.

Each step must be a non-empty string. Multi-line commands within a single step entry are not allowed — use one command per script: entry.

Ordering summary

RuleGuarantee
StagesOmitted needs waits for all executable jobs in all earlier declared stages
Jobs within a stageNo implicit dependencies; ready jobs may run concurrently
Jobs with needsThe explicit list replaces stage barriers; missing, self, and cyclic dependencies fail compilation
Artifact inputsAdd prerequisites independently of needs
Steps within a jobSequential, in list order; fail-fast on non-zero exit

Required job keys

Every executable (non-template) job must have these keys:

KeyTypeDescription
stagestringMust match a name declared in root stages:
targetstringMust be "linux" (the only supported target)
scriptsequence of stringsAt least one non-empty command string

Template jobs (names starting with .) require at least one of script or extends.

Optional job keys

KeyPurpose
imageContainer image — triggers Docker provider (see Providers)
variablesJob-scoped environment variables (see Variables)
secretsJob-scoped secret references (see Secrets)
needsExplicit dependencies that replace the job's stage barriers
artifact_inputsArtifact producers that add prerequisites and restore declared files
extendsInherit configuration from a template job
servicesSidecar containers (requires image to be set)
cacheCache configuration for the job
runner_poolRunner pool assignment
invariantInvariant metadata

The default block

The default block sets inherited defaults for all jobs. Job-level values override these defaults through a deep merge — nested mappings (like variables) are merged key-by-key, not replaced wholesale.

default:
target: linux
variables:
LANG: "en_US.UTF-8"

Allowed keys in default:

KeyPurpose
targetDefault target for all jobs
imageDefault container image
runner_poolDefault runner pool
variablesDefault variables (merged with job variables)
invariantDefault invariant metadata
cacheDefault cache configuration
servicesDefault sidecar services

secrets is not allowed in default — secrets must be declared per job because they are job-scoped.

Diagnostics: from failure to root cause

The three-level hierarchy maps directly to runtime log paths. When a job fails, Loom writes structured pointers that let you jump straight to the failing step without reading aggregated logs.

Log path structure

.loom/.runtime/logs/<run_id>/
├── pipeline/
│ ├── summary.json ← overall run status
│ └── manifest.json ← pointers to failing job(s)
└── jobs/
└── <job_id>/
├── summary.json ← job status + exit code
├── manifest.json ← pointer to failing step
├── user/
│ └── execution/script/
│ ├── 01/
│ │ └── events.jsonl ← step 1 event stream
│ └── 02/
│ └── events.jsonl ← step 2 event stream
└── system/
└── provider/
└── events.jsonl ← provider-level events

Following a failure pointer

  1. Read the job manifest at .loom/.runtime/logs/<run_id>/jobs/<job_id>/manifest.json.
  2. Find the failing_step_events_path field — it points directly to the failing step's event stream.
  3. Read the step events file — it contains the exact command, exit code, and stderr excerpt.

Example manifest:

{
"failing_section": "script",
"failing_step_index": 2,
"failing_step_events_path": "jobs/check-pnpm/user/execution/script/02/events.jsonl",
"system_sections": [
{
"system_section": "provider",
"events_path": "jobs/check-pnpm/system/provider/events.jsonl"
}
]
}

Use the pointer to read the failed script entry's output. If the job failed in provider setup, follow its system-section pointer instead.

Common pitfalls

Missing required keys

  • Symptom: loom check reports errors like add required key stage for this job or add required key target with value "linux".
  • Fix: Ensure every executable job has stage, target, and script. Template jobs need at least script or extends.

Can't find the failure in logs

  • Symptom: You're reading full stdout/stderr logs but can't identify what failed.
  • Fix: Follow the structured pointer path: pipeline manifest → job manifest → failing_step_events_path → step events.jsonl. See the Diagnostics ladder for the full triage flow.

Unexpected execution order

  • Symptom: Jobs run in a different order than expected.
  • Fix: Jobs that omit needs wait for every earlier-stage job. An explicit needs list replaces that barrier, and needs: [] can start immediately. Ready jobs may run concurrently. Run loom compile to inspect the resolved dependency graph.