Workflow schema v1
This is the definitive reference for Loom's schema v1 — every key, type, constraint, and validation rule enforced by loom check.
Workflow files are authored in YAML and validated against schema v1.
- Validate:
loom check - Run (local):
loom run --local
If you're trying to author a workflow, start with Hello Loom and Syntax (v1). Come back here when you need the exact rule for a specific key.
Root structure
The root must be a YAML mapping. Two keys are required; the remaining schema keys are optional. Any other key is treated as a job definition.
| Key | Required | Type | Description |
|---|---|---|---|
version | yes | string | Must be the exact string "v1". |
stages | yes | string[] | Non-empty sequence of stage names. Defines execution order. |
include | no | sequence | Local or public remote includes with rules and file-scoped inputs. |
inputs | no | mapping | Per-document typed configuration input declarations. |
selection | no | mapping or null | Compile-time workflow selection; see below. |
workflow | no | mapping | Workflow-wide rules; non-empty v1/v2 if conditions are Experimental. |
variables | no | mapping | Workflow-level variables. Keys: UPPER_SNAKE_CASE → string values. |
default | no | mapping | Default job keyword values merged into every job. |
fragments | no | mapping | Named reusable command values; see command phases. |
<job_name> | no | mapping | Any key matching the job naming pattern is treated as a job definition. |
Unrecognized root keys that don't match the job naming pattern produce a schema error.
Naming patterns
| Entity | Pattern | Max length | Notes |
|---|---|---|---|
| Stage name | ^[a-z][a-z0-9_-]{0,31}$ | 32 chars | Lowercase, starts with letter. |
| Job name | ^\.?[a-z][a-z0-9_-]{0,63}$ | 64 chars | Dot prefix (.) marks a template job. |
| Variable key | ^[A-Z_][A-Z0-9_]*$ | — | UPPER_SNAKE_CASE. Applies to both variables and secrets. |
version
Must be the exact scalar string "v1". Any other value fails validation.
stages
A non-empty YAML sequence of unique stage names. Each stage name must match ^[a-z][a-z0-9_-]{0,31}$. Duplicate stage names are rejected.
stages: [ci, build, deploy]
Configuration inputs
Each native document may declare inputs, a mapping from literal string names to
null (a required string) or declarations with type, default, description,
options and regex. Omitted type means string; the other types are
number, boolean and array. Explicit type: null is invalid. Descriptions
accept strings, integers or null. Unknown attributes and caller names fail.
inputs:
prefix: {}
enabled: { type: boolean, default: false }
include:
- local: ci/unit.yml
inputs: { prefix: left, enabled: "$[[ inputs.enabled ]]" }
GitLab reusable files declare spec:inputs in a header followed by --- and the
configuration body. Header spec.description accepts a string of at most 256
characters or null. GitLab splits at most twice on a line beginning --- plus
whitespace, ignores empty parsed chunks, and uses the last retained chunk as the
body when the first contains spec. Original source coordinates and bytes are
retained. GitLab declaration and local argument names retain Integer versus
textual keys: Integer 42 and String "42" bind independently. JSON caller keys
are textual, and inputs.42 addresses only the textual declaration. Native
declaration and argument names remain literal strings. In valid GitLab block
YAML, unquoted :foo and :"42" scalars are authored Symbols; quoted colon
strings stay Strings. This parsing does not reinterpret JSON, native or inserted
strings.
Nested input data preserves Integer and Boolean mapping keys. Native member lookup uses literal String keys. GitLab definition/default maps use indifferent lookup: Symbol member names address their String keys. Actual local YAML and root JSON caller maps use Symbol textual keys. A JSON-encoded String parsed later as an array instead contains ordinary String-key maps: selecting its whole row works, but GitLab member lookup on that row fails. Forwarding the selected row through another file applies that file's caller normalization. These key classes also affect Ruby inspection and options equality; they never stringify Integer or Boolean keys to satisfy a textual member. Consuming fields retain their accepted shapes.
A default's presence makes an input optional, including default: null.
Caller null means omitted. False, zero, empty strings and empty arrays remain
specified values. Non-null defaults must validate even when overridden.
A null default bypasses value constraints and yields an empty string for string
inputs, or null for other types; the consuming field still applies its rules.
options accepts null or at most 50 non-null members. Strings normalize option
values textually, numbers compare numerically, and arrays follow Ruby array
difference membership, including Integer versus Float distinctions.
Boolean options and regex on non-string types fail when a value is validated.
String regex uses direct RE2 syntax, without slash delimiters or a fallback.
A string value with regex: null fails safely; a null default retains its bypass.
Use $[[ inputs.name ]] in mapping keys, values and include arguments. Each file
binds and interpolates before its includes expand or merge, so forwarding is
explicit and repeated includes bind independently. A whole replacement preserves
its type. Embedded replacements use Ruby textual conversion, including Ruby array
and hash spelling; shell commands retain ordinary authored shell semantics.
A whole array item splices an input array one level. Inserted containers are not
interpolated again. Scalar substitution runs once for each original ordered
match, so a repeated original block can replace scalar text introduced by an
earlier pass. Authored duplicate keys fail; keys that collide after
interpolation use the later assignment before ordinary cross-file deep merge.
Member and array access
Use spaced blocks such as $[[ inputs.rows[0].name ]] or
$[[ inputs.grid[1][0].commands ]]. Whole selected values preserve false, null,
empty strings, numbers, maps and arrays. An array selected as a script item
splices one level. Missing members, non-array indexing and out-of-range indices
fail at the authored use site without disclosing input values.
Access expressions allow 1024 UTF-8 bytes after stripping NUL and ASCII
space/tab/LF/VT/FF/CR. Unicode whitespace remains literal. The complete expression
splits at its first four dots; the fifth segment is a literal unsplit remainder,
including any further dots. Empty member segments are retained. Plain member
names have no identifier restriction. An indexed segment requires a nonempty
ASCII letter/digit/underscore/hyphen base followed by one to five nonnegative
decimal indices; the five-index bound applies separately to each segment.
Leading zeros are accepted. Keep a space before the closing ]]: the inherited
block matcher can truncate a terminal indexed spelling such as
$[[ inputs.rows[0]]].
Embedded selected values retain the ordered substitution described above. For
example, if x contains the literal $[[ inputs.y ]] and y is B, authored
$[[ inputs.x ]]-$[[ inputs.y ]] yields B-B; a single whole x replacement
retains its literal text. Inserted containers do not acquire another traversal.
Access parsing, default-map normalization and selected subtree copies use the
existing cumulative budgets and caller cancellation.
Number handling retains arbitrary integers, binary64 floats and the released GitLab/Oj decimal selection, including signed zero and nonfinite values. JSON transport never first rounds every number through binary64. Existing source-byte and concrete-node budgets also charge generated interpolation and JSON coercion before allocation, including option normalization, canonical typed input identity serialization and raw/direct caller admission. Configuration admission allows at most 4 MiB, 100,000 concrete nodes and the JSON decoder's 10,000 levels of nesting. No separate 1 KB input-value limit is imposed.
CLI check, compile and run accept an explicit project-relative --inputs FILE
containing a plain JSON object. MCP validate, compile, run_local and start_run accept
configuration_inputs. Top-level null, duplicate JSON keys, malformed/trailing
JSON and nonobject values fail admission; null object members remain available
for default handling. Inputs are separate from scalar variable contexts.
Graph/plan configuration evidence contains an aggregate digest and ordered
source-byte, validated-input and invocation digests, with source locations linked
to their invocation. Values are not projected into that evidence. Runs record
hash-only configuration identity in their existing events file after the
isolated compile, or on a terminal preflight plan. The accepted mutable preflight
probe never authors execution evidence.
The paired local-unit corpus
shows authored job prefixes, explicit producer references and pure input functions.
Conditional or shared declarations, expand_vars, automatic aliases/exports and
nonlocal/runtime input propagation remain separate required work.
Pure input functions
Both source formats apply up to three functions to an input selection, from left to right. Access resolves first; the entire function stack validates before any function executes. Each function requires an actual String, including when a member was selected from an array. Existing declared String coercion remains separate from selected typed values.
script: $[[ inputs.commands | split('§') ]]
variables:
SHORT: $[[ inputs.name | truncate(1,2) | posix_escape ]]
truncate(offset,length)selects Unicode code points, clips at the end and returns an empty String for zero length or an offset at/past the end. Arguments are nonnegative decimal integers in0..9223372036854775807; leading zeros are decimal. ASCII whitespace is allowed around the arguments; the closing)is optional under the released grammar. Both arguments convert before slicing, even for empty input or zero length. Overflow producesWF_INPUT_INVALID.posix_escapereturns the released Shellwords text: safe ASCII letters, digits and_-.,:+/@pass through, LF becomes single-quote/LF/single-quote, and other code points receive one preceding backslash. Empty input returns''. Output remains data; compilation does not execute it.split('separator')uses a literal String separator, strips ASCII whitespace and NUL from each field, and drops empty fields. Empty input returns a typed empty array; an empty separator fails. Exactly one ASCII space selects ASCII whitespace fields; multiple spaces are literal. NBSP remains data. Split must be the last function; there is no per-element function mapping.
Pipes separate tokens literally, including inside quoted separator text. Raw trailing empty tokens are discarded before ASCII/NUL stripping; a retained whitespace-only token is invalid. Whole split results retain array type and an authored script item splices them one level; embedded arrays use Ruby text. Generated Strings retain their type without YAML reparsing or another scan. Function work and output consume the existing shared configuration budget and observe caller cancellation. Diagnostics retain authored use sites and omit selected values and function arguments.
include
A YAML sequence of mappings with exactly one source, local or remote, and optional rules and file-scoped inputs. Remote entries also accept integrity and cache; local entries reject nonblank cache and truthy integrity controls.
include[].rules
Use ordered compile-time if expressions and when: always or when: never:
include:
- local: ci/producer.yml
rules:
- if: '$INCLUDE_PRODUCER == "yes"'
The first matching rule wins. Omitted when includes; no match excludes.
Absent or null rules include unconditionally. Native rules require a nonempty
sequence of nonempty mappings; false, empty rules, null if/when and job-only
actions are invalid. Expressions use the ordinary scalar predicate grammar from
compile selection.
Include expressions see only the explicit caller variable context. Native caller aliases follow native scalar expansion; GitLab include aliases remain raw strings. Undefined and defined-empty values remain distinct. Root/default/job variables, earlier included variables and the process environment do not enter this view. Nested remote sources have an anonymous view without caller variables.
All outer rule definitions validate before first-match selection. Excluded sources
are not expanded, read, given input invocations or charged source-occurrence budgets;
selected sources retain ordinary validation and containment. exists, changes,
variable addresses and complete include-phase provenance remain required future work.
include[].local constraints
- Must be a normalized path relative to the repository root, in any directory
- Must end with
.ymlor.yaml - Must not contain
.., absolute paths or backslashes; targets must remain authorized regular files
include:
- local: .loom/templates/go-jobs.yml
include[].remote controls
remote is a literal public HTTP(S) YAML URL. Addresses with credentials,
internal destinations, unsupported schemes or variable expansion fail before
execution. Nested remote files can include other public remote files, with
explicit per-file input forwarding; nested local reads cannot access the caller
repository. Native composition order remains unchanged.
integrity optionally supplies sha256- followed by strict base64. It checks
the exact fetched source bytes on every occurrence, including warm cache reads.
Absent, null or false skips this check. A URL alone is not immutable provenance:
plans retain the selected bytes' digest even when integrity is omitted.
cache: true reuses raw source bytes for one hour across operations. A duration
string of at least 60 seconds selects another TTL, for example 1 hour, 90s
or 1:30. The duration grammar includes unit aliases, compound quantities and
word numbers. Absent, null, false, blank strings and empty collections bypass
the disk cache. Cache entries live under .loom/.runtime/source-cache/v1/;
corruption or expiry causes a fresh retrieval. Inputs and composed YAML trees
are never cached. Remote cache controls are separate from job caches.
See public remote sources for source pinning, cancellation and provenance.
See Includes & templates for how included templates interact with extends.
workflow
A mapping with optional key rules. No other key is allowed. rules must be a
YAML sequence; it may be empty. Each rule entry must be a mapping with exactly
one non-empty scalar string field, if.
workflow:
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
Schema validation enforces the shape. Non-empty v1 and v2 rules are Experimental. The executor evaluates them against resolved pipeline variables and fails closed on invalid syntax, missing inputs, or type errors before provider setup or execution.
See Workflows → Rules for the rules syntax.
Literal include.local paths end in .yml or .yaml. Normalized relative star
patterns may use other suffixes; *, ** and **/ select ordered YAML sources.
Other glob punctuation is literal. Wildcard sources stay repository-contained
even when an external root is authorized for explicit literal/source reads. See
local star patterns
for eligible Git source bytes, non-Git roots, self-inclusion and limits.
selection
Root and job selection run during compilation, before dependency/stage edges and
requested-job closure. Existing workflow.rules and job rules remain runtime
gates on retained nodes. Root selection is never a job default;
default.selection is invalid.
selection:
rules:
- if: '$MODE == "blue"'
when: always
build:
stage: build
target: linux
script: [./build]
selection:
rules:
- if: '$MODE == "blue"'
- when: never
Omitted selection includes by default. selection: null clears inherited
selection and restores inclusion. A block allows only a nonempty rules list.
Each nonempty rule mapping allows an optional nonempty string if, optional
when, and job-only variables. Omitted if is unconditional. The first match wins; no match excludes.
Root inclusion uses always, job inclusion uses on_success; either scope
accepts never. Omitted when uses that scope's include action. Other actions,
unknown/duplicate keys and malformed types fail even in excluded source.
Inherited rule lists replace as a whole.
Job selection.rules[].variables uses the ordinary native variable mapping,
including rich values and expand: false/null. Omitted assignments and {} do
nothing; null/false/nonmapping collections are invalid. Only the first matching
included job rule applies. Its entries replace whole composed definitions before
the caller overlay, including an explicit empty caller value. Aliases and supported
cache fields are recomputed from the selected definitions. An action cannot change
its own predicate, another job's values, includes, inputs, fanout identity or
dependency topology. Malformed actions validate even in later rules or jobs
removed by selection. Workflow selection and retained runtime rules do not accept
variable actions.
The ordinary compile expression contract
also defines presence, phase and regex behavior. Root lookup uses root variables
and caller values; job lookup uses composed job variables and caller values.
Caller values win, including present-empty strings. Native aliases resolve before
selection; an evaluated unavailable runtime binding produces WF_SELECTION_PHASE.
No ambient process environment or fabricated branch facts enter lookup.
A known excluded requested job is unavailable; an undefined requested job is
unknown. A selected job requiring an excluded producer fails at its authored
need. Native Graph IR records optional compile_selection decisions with scope,
name, inclusion, reason, matched rule index and available source location. This
projection contains no predicate text, context values or effective-input digest.
variables
A mapping of UPPER_SNAKE_CASE keys to scalar values or rich definitions. Applies at the workflow level (available to all jobs).
variables:
GO_VERSION: "1.22"
NODE_ENV: production
Variable keys must match ^[A-Z_][A-Z0-9_]*$. Bare scalars keep their authored
text, including null spellings, numbers and booleans. A rich mapping accepts only
value, expand, description and options, consistently at root, default and
job locations. Missing/null rich value gives a present empty string; other
values must be scalars and keep their text. expand is boolean or null: omitted
or true expands references; false or explicit null preserves literal bytes.
description accepts string/integer/null; options accepts a string sequence or
null. A nonempty options list must contain the authored default text, before
caller overlay. Options do not restrict explicit caller values. Bare lists and
unknown rich fields fail validation. See rich values
for composition and literal-reference examples.
Values support recursive native scalar references
after composition and explicit caller context.
Caller context keys follow its merge contract rather than authored YAML's
uppercase rule; effective variable/secret collisions remain invalid.
default
A mapping of job keyword defaults that are merged into every job definition. Supports these keys:
| Key | Type | Description |
|---|---|---|
target | string | Default execution target: "linux" or "darwin". |
before_script | commands | Default setup command sequence. |
after_script | commands | Default finalization command sequence. |
image | string or mapping | Default container image. See image. |
runner_pool | string | Default runner pool identifier. |
variables | mapping | Default variables merged into each job. |
invariant | mapping | Default invariant configuration. |
cache | mapping or sequence | Default cache configuration. See cache. |
services | sequence | Default sidecar services. See services. |
rules | sequence | Default native runtime job predicates. |
Use job inherit.default to select all, none or named native default keywords.
Ordinary maps merge recursively, services replace as a unit and caches use their
named-entry merge contract.
default.secrets is explicitly invalid and fails validation. Secrets must be declared per-job.
Jobs
Any root key matching the job naming pattern (^\.?[a-z][a-z0-9_-]{0,63}$) is treated as a job definition. Jobs whose name starts with . are template jobs.
Job-level keys
| Key | Type | Required | Description |
|---|---|---|---|
stage | string | yes (non-template) | Must reference a name declared in stages. |
target | string | yes (non-template) | Execution target: "linux" or "darwin". |
before_script | commands | no | Optional setup command sequence. |
script | commands | yes (non-template) | Non-empty sequence of command strings. |
after_script | commands | no | Optional finalization command sequence. |
extends | string or string[] | no | Ordered hidden or executable mapping parents. |
inherit | mapping | no | Default/root-variable filters; see includes and templates. |
selection | mapping or null | no | Compile-time job selection, inherited through extends. |
rules | sequence | no | Native runtime job predicates. |
needs | — | no | Accepted by schema. Used for dependency declarations. |
image | string or mapping | no | Container image. See image. |
runner_pool | string | no | Runner pool identifier. |
variables | mapping | no | Job-scoped variables. |
secrets | mapping | no | Job-scoped secrets. See secrets. |
invariant | mapping | no | Invariant configuration. |
cache | mapping, sequence, or null | no | Cache configuration. See cache. |
services | sequence | no | Sidecar services. See services. |
artifacts | mapping | no | Artifact publication. See artifacts. |
artifact_inputs | sequence<string|mapping> | no | Producer artifacts to restore before this job runs. |
allow_failure | boolean | no | Allow main-script failure. Default false. |
Unknown job keys are rejected. The complete allowed set is: parallel, stage, target, before_script, script, after_script, extends, inherit, rules, selection, needs, image, runner_pool, variables, secrets, invariant, cache, services, artifacts, artifact_inputs, allow_failure.
Non-template job requirements
Non-template jobs must have all three: stage, target, and script.
The resolved target must match the selected provider: Linux and macOS host jobs
use linux and darwin respectively; Docker image and image-build jobs require
linux. Unsupported targets and host OS mismatches fail before commands start.
Template job requirements
Template jobs (dot-prefixed names like .go-base) may contain configuration
only. Their authored fields remain validated; they are not executable jobs.
Command phases
before_script, script, and after_script accept a nonempty string, nested
command lists up to ten array levels, or exactly {fragment: name}. Named
commands are defined under root fragments; names are nonempty strings without
whitespace/control characters. Fragment chains and copies share the existing
100,000-node YAML materialization budget and cycles are rejected. The normalized
result contains only nonempty strings, in order. Commands retain their order and YAML-decoded scalar content,
including literal and folded block scalars.
before_scriptis optional and compiles to graphsetup.scriptis required and non-empty on every resolved executable job.after_scriptis optional and compiles to graphfinalization.before_script: []/nullandafter_script: []/nullclear inherited commands.script: [],script: null, booleans, numbers and other command mappings are invalid.- Default, template, and child sequences replace one another as complete values; they are not concatenated.
script
A nonempty string, command list or named fragment use that resolves to at least one nonempty string command.
- Empty strings are rejected.
- Literal block scalars preserve embedded newlines. Folded block scalars preserve their YAML-decoded folded content.
script:
- echo "step one"
- make build
- ./run-tests.sh
allow_failure
A strict boolean accepted on jobs and template jobs. Omission means false. A job
inherits the value from its template, and an explicit child value overrides it.
When true, an ordinary nonzero exit from the main script may satisfy dependency
ordering. Loom retains the command's failed status, exit code, and reason. Setup,
cancellation, timeout, provider, artifact publication, finalization, and cleanup
failures remain fatal.
image
Controls whether a job runs on the Host or Docker provider and which container image is used. Accepted at both default and job level.
Scalar form
A non-empty string naming a Docker image (pullable by the Docker daemon):
image: node:20-slim
Mapping form
| Key | Type | Required | Description |
|---|---|---|---|
name | string | yes | Non-empty image reference. |
pull_policy | string | no | always, if-not-present (default), or never. |
user | string or integer | no | Nonblank username, UID, or UID:GID. |
build | mapping | no | Build configuration for the image. |
Unknown keys under image are rejected.
pull_policy controls acquisition of named prebuilt images. It cannot be used
with image.build; built images omit acquisition policy in compiled Graph IR.
Scalar image shorthand defaults to if-not-present.
user selects the identity for job-container processes. Nonblank strings are
preserved exactly. Nonnegative YAML integer UIDs are normalized to unsigned
base-10 text. Blank strings, booleans, nulls, floats, sequences, mappings, and
negative integers are rejected. Omission leaves the image's configured runtime
user unchanged. user is an image subkey and is not valid at job level.
image.build
| Key | Type | Required | Description |
|---|---|---|---|
context | string | yes | Build context directory. Maps to docker build context. |
dockerfile | string | yes | Dockerfile path. Maps to docker build --file. |
output | string or mapping | no | Forwarded to docker build --output. |
Unknown keys under build are rejected.
image:
name: my-app:latest
build:
context: .
dockerfile: Dockerfile
Runtime notes
- When no
imageis specified, the job runs on the Host provider.
secrets
Declares sensitive values resolved at runtime from external providers and injected into the job environment. Exact matches to resolved secret values are redacted in supported console, state and event output; transformed values and raw declared artifacts are not covered.
Placement rules
- Allowed: per-job blocks only.
- Disallowed:
default.secretsis explicitly invalid and fails schema validation. This minimizes accidental fan-out of secrets across jobs.
Secret spec shape
<job_name>:
secrets:
<SECRET_NAME>:
ref: <provider-uri>
file: true
required: true
Fields
| Key | Type | Required | Default | Description |
|---|---|---|---|---|
ref | string | yes | — | Provider reference URI (env://, keepass://, op://). Must be non-empty. |
file | boolean | no | true | true: inject temp-file path. false: inject raw value. |
required | boolean | no | true | true: fail on unresolved. false: silently omit. |
Validation rules
- Secret names must match
^[A-Z_][A-Z0-9_]*$(same as variable keys). refmust be non-empty. Scheme/shape validity is enforced during secrets resolution.fileandrequiredmust be booleans.- A key cannot appear in both
variablesandsecretsfor the same effective job after merge — this fails with a schema/planner error.
Supported provider URI schemes
| Scheme | Format | Status |
|---|---|---|
env:// | env://<ENV_VAR_NAME> | Implemented |
keepass:// | keepass://<db-alias>#<entry-path>:<field> | Implemented |
op:// | op://<vault>/<item>/<field> | Implemented (service account token; no op CLI required) |
Injection behavior
file: true(default): value written to a node-scoped temp file (0600permissions). The variable is set to the file path. Docker providers bind-mount and rewrite to container-local paths.file: false: variable set directly to the secret value. Blocked whenCI_DEBUG_TRACE=true.
Redaction
Configured exact values are replaced before Loom persists verified textual sinks (state.jsonl and events.jsonl, output-bearing error fields, and provider lifecycle messages). Redaction replaces matches with [REDACTED:SECRET_<NAME>]. Inspect and sanitize runtime evidence and declared artifacts before sharing them.
Error codes
Resolution failures produce structured SECRETS_* error codes. See Secrets error codes.
Example
deploy:
stage: ci
target: linux
image: loom:nix-local
secrets:
DATABASE_PASSWORD:
ref: env://DEPLOY_DB_PASSWORD
file: true
API_TOKEN:
ref: env://DEPLOY_API_TOKEN
file: false
script:
- ./scripts/deploy.sh
Related pages
- Secrets concept — mental model, file-vs-env injection, redaction
- Secrets workflow authoring — YAML syntax, provider URIs, worked examples
- Secrets error codes — all
SECRETS_*error codes
artifacts
Declares files to publish from the workspace that executed the job. Host and Docker bind-mount jobs match files in the isolated execution snapshot. Ephemeral-volume jobs export from the execution volume. Published artifacts are written to the live checkout's .loom/.runtime/artifacts/<run_id>/jobs/<job-id>/, preserving relative paths.
Artifact mapping keys
| Key | Type | Required | Description |
|---|---|---|---|
paths | string[] | yes | Non-empty sequence of glob patterns matching files/directories to publish. |
exclude | string[] | no | Glob patterns for files to exclude from publication. |
name | string | no | Human-readable name for the artifact set. |
when | string | no | When to publish: on_success (default), on_failure, or always. |
required | boolean | no | Default false. If true, a due declaration fails when no configured path matches. |
Unknown keys under artifacts are rejected.
build:
stage: ci
target: linux
image: node:20-alpine
script:
- npm run build
artifacts:
paths:
- dist/
exclude:
- dist/**/*.map
name: build-output
when: on_success
required: true
artifacts:when values
| Value | Behavior |
|---|---|
on_success (default) | Publish only when the command/provider execution succeeds. |
on_failure | Publish only when command/provider execution fails. Useful for test reports or core dumps. |
always | Publish regardless of command/provider outcome. |
Runtime notes
- Secret cleanup finishes before artifact publication. Publication messages and failures appear in the run's
events.jsonlwithjob_id; the final job state records the resulting job outcome. - Published files remain under
.loom/.runtime/artifacts/<run_id>/jobs/<job-id>/, separate from the run's two JSONL files. The archive isartifacts.tar.gzin that job's artifact directory when files are published. - If no files match after exclusions, publication succeeds with zero files and no archive by default. With
required: true, the due publication fails. - A
whencondition that is not due is skipped even whenrequired: true; provider export failures are evaluated beforewhenand still fail publication. - File-secret cleanup, copy, permission, and archive failures fail a due publication. When publication is not due, it remains skipped; a file-secret cleanup error still fails the job's cleanup independently.
required: falseonly makes the no-match case optional. - Paths are relative to the execution workspace, never the live checkout. For ephemeral-volume jobs, the provider exports candidate roots from the execution volume and Loom applies the same matcher in staging.
- Literal directory paths may end with
/(for example,dist/) and include their descendants. - The root output name
artifacts.tar.gzis reserved for Loom's generated archive. A declaration that selects that exact root path fails publication; the same basename is valid in subdirectories. - Ephemeral-volume candidates are exported to user-owned staging. Loom applies includes and excludes there, copies only selected files to the final directory, and removes staging on success, skip, or failure.
Executing materialized files
Published archive members and files restored through artifact_inputs use mode
0644. An executable permission bit from the producer is not retained. Run a
script through its interpreter, or explicitly prepare an intended executable in
the consumer:
consume:
stage: test
target: linux
image: alpine:3.22
artifact_inputs: [build]
script:
- sh out/check.sh
- chmod +x out/check.sh
- ./out/check.sh
The producer build must declare out/check.sh as an artifact. Choose the
interpreter required by that script. For a binary, executable permissions alone
do not supply its target operating system, architecture, dynamic loader or
libraries. Contributor builds made in Nix can require Nix runtime dependencies;
use the release installation procedure for a
distributed Loom installation.
artifact_inputs
Lists producers whose declared artifacts Loom restores into this job's workspace before its first command. The value is always a sequence; without inherited inputs, omission and [] both request no artifact files. null and other non-sequence values are invalid. Each entry is a producer job ID string or a mapping with job and optional availability.
build:
stage: build
target: linux
script: [npm run build]
artifacts:
paths: [dist/]
test:
stage: test
target: linux
artifact_inputs: [build]
script: [npm test]
Each producer must be an executable job with an artifacts declaration. Missing producers, self references, duplicate entries, and dependency cycles fail compilation at the indexed artifact_inputs source path. needs only orders jobs and never transfers files; declaring both for the same producer retains artifact transfer while counting as one ordering prerequisite.
Loom restores producers in stable dependency-plan order, rather than authored
artifact_inputs list order. A later producer's regular file replaces an earlier
producer's regular file at the same path. All archives are verified and closed in
private staging before consumer files change. File/directory conflicts, unsafe
paths, links and duplicate regular-file entries within one archive remain errors.
Restore file counts include validated replacements, not only distinct final paths.
GitLab artifact needs use the same materializer; this does not promise that a
GitLab server's download order follows its authored needs list.
availability: required is the default, including for string entries. It blocks
the consumer if no successful artifact publication exists. Use
{job: build, availability: available} to restore an archive only when the
producer published one. No publication produces an explicit skipped transfer
record; it does not invent empty artifact bytes or change job scheduling.
Unknown producer references remain errors. Invalid publication descriptors,
deleted or malformed archives, and restore failures remain errors for both
policies.
The mapping accepts only job and availability. job must be a nonempty
producer ID string; availability must be exactly required or available
when present. Explicit null, empty strings, booleans, numbers, sequences and
mappings do not substitute a default policy.
When a job inherits artifact_inputs through extends, omission retains the inherited sequence and its policies. Its own sequence replaces the inherited sequence as a whole, so {job: build} resets that entry to the required default. Set artifact_inputs: [] to clear inherited inputs; null does not clear them.
services
Declares sidecar containers that run alongside the job. Accepted at both default and job level.
Must be a YAML sequence. Each entry is either a scalar image string or a mapping.
Scalar form
A non-empty string naming the service image:
services:
- postgres:15
- redis:7
Mapping form
| Key | Type | Required | Description |
|---|---|---|---|
name | string | yes | Non-empty image reference for the service. |
pull_policy | string | no | always, if-not-present (default), or never. |
user | string or integer | no | Nonblank username, UID, or UID:GID for this service. |
alias | string | no | Network alias for the service container. |
entrypoint | string[] | no | Override container entrypoint. Non-empty sequence of strings. |
command | string[] | no | Override container command. Non-empty sequence of strings. |
variables | mapping | no | Service-specific environment variables. |
Unknown keys are rejected. The following keys produce explicit "not supported yet" errors: docker, kubernetes.
services:
- name: postgres:15
pull_policy: never
user: postgres
alias: db
variables:
POSTGRES_DB: testdb
POSTGRES_PASSWORD: test
Scalar service shorthand and mapping entries without pull_policy default to
if-not-present. Each service policy is compiled independently.
Each service mapping can set its own user. Nonblank strings are preserved
exactly, while nonnegative YAML integer UIDs are normalized to unsigned base-10
text. Invalid types, blank strings, and negative integers are rejected at that
service entry's user path. Omission leaves the service image's configured
runtime user unchanged.
Runtime notes
Services are supported in Docker jobs (those with image: set).
cache
Configures job-level caching. Accepted at both default and job level.
Accepted forms
| Form | Meaning |
|---|---|
cache: null | Explicitly disables cache for this job. |
cache: [] | Explicitly disables cache for this job. |
cache: {paths: [...], ...} | Single cache mapping. |
cache: [{name: ..., paths: [...]}, ...] | Sequence of named cache entries. |
Cache mapping keys
| Key | Type | Required | Description |
|---|---|---|---|
name | string | yes (sequence) | Unique name for the cache entry. Required when cache is a sequence. |
disabled | boolean | no | Disables this cache entry when true. |
paths | string[] | yes (unless disabled) | Non-empty sequence of paths to cache. |
key | mapping | no | Cache key specification. Allowed sub-keys: prefix and files. |
fallback_keys | string[] | no | Fallback cache keys tried if primary key misses. |
policy | string | no | One of: pull, push, pull-push. |
when | string | no | One of: on_success, on_failure, always. |
Unknown cache keys are rejected.
Example
cache:
paths:
- node_modules/
key:
prefix: deps
files:
- pnpm-lock.yaml
policy: pull-push
when: on_success
See Workflows → Cache for key placeholder syntax and strategies.
Minimal valid example
The smallest workflow that passes schema v1 validation:
version: v1
stages: [ci]
check:
stage: ci
target: linux
script:
- echo "hello"
Validator vs runtime semantics
Schema v1 has two layers:
- Validator contract — what
loom checkenforces (YAML shape + constraints). Everything on this page. - Runtime semantics — what
loom rundoes when executing jobs (provider routing, service lifecycle, caching behavior, artifact writes).
Some keywords are accepted by the schema for forward compatibility, but their runtime effect may be partial or planned. When in doubt, treat what's documented on Syntax (v1) as the contract.
Runtime-only behavior (not validated by loom check)
- Provider routing — host vs Docker based on
imagepresence. See Providers. - Services lifecycle — sidecar containers via Docker provider. See Docker provider.
- Artifact publication — files from the execution workspace or provider export are copied to
.loom/.runtime/artifacts/<run_id>/jobs/<job-id>/, with an archive atartifacts.tar.gzin that job's artifact directory when files exist. See Runtime logs contract.
Versioning policy
Schema v1 is expected to evolve:
- Additive changes remain compatible within v1 when possible.
- Breaking changes ship as a new schema version with migration notes.
See the CLI overview for the current command surface.
Cross-references
- Syntax (v1) — narrative keyword guide with worked examples
- Troubleshooting → Common failures — error signatures and first actions
- Includes & templates — how
includeandextendscompose
Integer job fanout
Native and GitLab jobs accept parallel: N, an exact YAML integer from 1 to 200.
Explicit 1 creates job 1/1; omission creates ordinary job. Children inherit
a parent's count; parallel: null clears it. Typed configuration inputs may
supply counts. Boolean, string, Float (including 2.0), sequence, map and
out-of-range counts fail before rules or requested selection can hide them.
parallel is a job/template keyword, not a root or default keyword.
Each member is an ordinary graph node named job i/N, with neutral
instance: {source_job: job, index: i, total: N} metadata. Graphs and GitLab CLI
reports retain integer_fanout groups with numeric member order and original
totals. Names are never decoded to infer metadata. GitLab exact name collisions
use declaration order: the last inserted complete payload wins. An ordinary
winner remains ordinary even when its name looks generated. Collision winners
are fixed before job selection: excluding an ordinary winner does not resurrect
the overwritten generated job.
Job selection then evaluates each winning instance with structural index/total
facts. Native selection.rules retains its normalized alias view; GitLab
rules:if reads raw alias strings, so use direct $CI_NODE_INDEX and
$CI_NODE_TOTAL predicates there. Both retain explicit variable/caller winners,
including empty values. Root selection/workflow rules receive no synthesized
instance facts. Decisions carry final job IDs, optional instance metadata and
authored source locations, including excluded members.
A need on a source includes all normalized members. Required absent members fail;
optional GitLab needs omit absent members. For integer fanout, GitLab base needs
normalize source aliases before rules. Every base list and every rules:needs
list validates before job rules or requested selection: duplicate final names
fail, including optional duplicates. Rule overrides use literal final IDs,
such as ["shard 2/3"], and never expand a parallel source alias. Required
missing literal names fail; optional missing names omit. The complete authored source index
stays source-first even when an excluded source name also names a surviving
exact node. Native needs still orders only;
artifact_inputs transfers publishing archives. Exact generated references use
the resolved catalog. --job shard (MCP requested_jobs) selects available automatic source members;
--instance 'shard 2/3' (requested_instances) selects one exact final node,
even when its name is also a source alias. Both include prerequisite closure. All-excluded/manual source requests and exact
excluded instance requests are unavailable; unknown names remain distinct.
Real instances receive job-phase CI_NODE_INDEX and CI_NODE_TOTAL; ordinary
jobs have no index and total 1. Explicit variable/caller winners, including
empty values, override defaults. Instance-dependent aliases bind in artifact
paths/excludes and cache paths, key/prefix/fallback fields after job variables.
Serialized literal/reference parts bind once: inserted dollars stay literal,
and an expanded empty cache key fails. Existing unbound native lowercase cache
templates retain their behavior. This does not implement every GitLab path or
default-operator grammar.
loom run --parallel caps runnable expanded nodes through the ordinary scheduler.
Artifact storage IDs stay stable across selection/order changes. Discover each
canonical jobs/<id>/artifacts.tar.gz in the original job's archive-created log
event, then use public artifact list/read commands. Explicit shared cache keys
stay shared; author an index-dependent key to separate members. Existing
100,000-node/4 MiB generated-content limits cover repeated logical graph payloads
and edges before materialization. Matrix fanout remains unsupported.