loom check
Validate a Loom or GitLab workflow and emit a receipt without executing jobs.
Use repeatable --job <source-or-id> to validate selected jobs and their prerequisite
closure. The receipt and console include job_selection with sorted
requested_jobs, effective_jobs and a stable scope identity. A selected check
does not verify the entire workflow. Empty, whitespace-padded and unknown IDs are
rejected; commas are literal and duplicate IDs form one set.
Without selectors or integer fanout, native checks retain schema-only validation. With explicit jobs, native checks also compile the dependency graph to validate the selected closure. GitLab checks retain capability-aware planning in both cases. No jobs execute.
For integer fanout, --job shard selects all source members before prerequisite
closure. --instance 'shard 2/3' selects only that exact final node, even when its
name is also a source alias. Repeat or combine both flags. job_selection binds
both request sets and effective jobs into its identity. See
integer fanout.
Canonical command usage
Use the
core CLI command reference for the exact
loom check synopsis, flags, and displayed defaults.
Workflow source and context
Omitted flags fall back to the defaults block of the layered runtime config
(see runtime config syntax); explicit flags
always win.
Without flags, Loom selects .loom/workflow.yml, then .loom/workflow.yaml,
before the repository root .gitlab-ci.yml. --format loom|gitlab selects
exactly one parser; read or parser errors never fall back to the other format.
--workflow accepts a project-relative trusted path. Without --format, Loom
infers the format from the file content: a root version key or a job with
target means Loom; other ordinary mappings mean GitLab. Include-only mappings
are indeterminate so a native version may come from an included source. When
content is indeterminate, an explicit context envelope hints its frontend;
otherwise the exact .gitlab-ci.yml basename selects GitLab. A Loom-schema failure
on a GitLab-shaped file ends with this file looks like GitLab CI; pass --format gitlab.
GitLab sources may use !reference [job, key, ...] tags; they resolve after
include: and before extends:, nest at most 10 levels, and are the only
explicit YAML tag Loom accepts (see the
keyword compatibility matrix).
Native --context accepts the strict
loom.variable-context.v1 envelope,
including an empty variables map. Unscoped native checks validate scalar
semantics and cycles with or without context. Explicit format/content wins over
a context hint. Config defaults.context remains GitLab-only.
For GitLab, --context accepts a project-relative JSON file with schema
loom.gitlab-pipeline-context.v1, or a short name without a path separator or
.json suffix that resolves to .loom/contexts/<name>.json (generate one with
loom context init). Its variables and changes drive GitLab
rules and expansion. runner_tags describes local runner capabilities used to
satisfy or report plan requirements; it is not job-selection data. Ambient CI
variables and runtime settings are ignored. Context files are limited to 1 MiB.
changes needs base and head; paths is optional. When a matched rule has
rules:changes and the context supplies no paths, Loom resolves the changed
paths with git diff --merge-base in the repository root, mirroring GitLab's
compare_to semantics (diff of merge_base(compare_to, head)..head). The base
is the rule's compare_to (variables resolve in the rule phase) or
changes.base; the head is changes.head, or the working tree when
compare_to is set and the context has no changes (recorded as head HEAD
with worktree: true: staged and unstaged tracked files, untracked excluded).
Explicit paths always win and git is never consulted. An unknown ref, a
shallow clone without a merge base, or a missing git fails closed with
GL_RULE_CHANGES_UNAVAILABLE and a non_executable plan. rules:changes
accepts the list shorthand (changes: ["docs/**/*"]) and the
paths/compare_to mapping; glob brace alternation ({a,b}) and character
classes ([ab]) are rejected as GL_SOURCE_UNSUPPORTED.
Outputs
| Artifact | Location | Description |
|---|---|---|
| Receipt | .loom/.runtime/receipts/<timestamp>-check-<pid>.json | Structured result, selected source, safe GitLab plan facts, and errors |
| Schema errors | stderr | Human-readable error lines on validation failure |
Exit codes
| Code | Meaning |
|---|---|
0 | Loom source is valid, GitLab plan is executable, or GitLab workflow is skipped |
1 | Source is invalid, GitLab plan is non-executable, or the command fails |
Output streams
All output goes to stderr — stdout is not used.
| Stream | Content |
|---|---|
| stderr | loom check passed or loom check skipped, diagnostic lines, and receipt path |
| stdout | (unused) |
Example output
Success
loom check passed
receipt: .loom/.runtime/receipts/20260304T183012Z-check-48291.json
Failure
workflow.yml:12 missing required key "stage" in job "build"
workflow.yml:18 expected sequence for "script", got string
receipt: .loom/.runtime/receipts/20260304T183012Z-check-48291.json
loom check failed (exit code 1)
GitLab failure
.gitlab-ci.yml:43:13 GL_SOURCE_UNSUPPORTED: rule when is preserved but not executable yet [/deploy/rules/0/when]
hint: use when: on_success, never, manual or always, or omit when to keep the GitLab default
receipt: .loom/.runtime/receipts/20260304T183012Z-check-48291.json
loom check failed (exit code 1)
Blocking GitLab diagnostics print PATH:LINE:COL CODE: MESSAGE [pointer]: the
source path relative to the repository root, the line and column, the diagnostic
code, the message, and the JSON pointer into the document. Diagnostics without a
recorded source location fall back to CODE POINTER: MESSAGE. Findings with a
known fix add an indented hint: line. Hints are presentation only; the receipt
records each diagnostic's code, pointer, location, and message unchanged.
When the plan is executable or skipped by workflow: rules, advisory GitLab
diagnostics for the plan or automatically selected jobs print as warning: lines
before the result. Every blocking finding remains visible on failure. Diagnostics
that share a code, message, and source location print once with an eligible job count,
for example (7 jobs) for a default: interruptible: true entry, so a
default applied to every job takes one line, naming the first eligible job's
pointer. The receipt retains all findings, including those for inactive jobs.
Receipt structure
The receipt JSON captures the validation result so downstream tools can parse it programmatically.
{
"schema_version": "v1",
"kind": "loom-check",
"loom_version": "0.4.2",
"command": ["loom", "check", "--workflow", ".loom/workflow.yml"],
"started_at": "2026-03-04T18:30:12.104Z",
"finished_at": "2026-03-04T18:30:12.131Z",
"duration_ms": 27,
"duration_us": 27412,
"exit_code": 0,
"status": "success",
"workflow_schema_v1": {
"workflow_path": "/repo/.loom/workflow.yml",
"valid": true,
"errors": []
}
}
command is the invocation as typed, with the binary path replaced by loom,
so a receipt can be replayed with the same format, workflow, and context flags.
duration_ms and duration_us measure the same span from started_at to
finished_at, which covers source resolution, planning, and the git lookup;
duration_us is the precise value when a check finishes in under a millisecond.
GitLab receipts replace workflow_schema_v1 with source and gitlab_plan.
The plan projection includes state, selection identity, selection records,
requirements, diagnostics, and resolved_changes without scripts or resolved
context values. Each resolved_changes entry records one git comparison as
{base, head, paths, worktree}; the list is empty when every matched
rules:changes used explicit context paths, and the selection identity covers
the resolved paths so plans over different diffs never share an identity.
rules:changes globs follow GitLab's fnmatch flags: * and ? never cross a
slash, **/ recurses, a leading dot is an ordinary character, [abc] and
[!abc] classes and {a,b} alternation (nested) are supported, and \ escapes
the next character. Variables in changes.paths are still reported as
unsupported.
workflow_skipped records status skipped and exits 0. source_invalid and
non_executable record status failure and exit nonzero.
GitLab validation reports missing runner tags, privileged services, hosted macOS,
OIDC tokens, Pages, and environments as requirements. Check never grants
privileged-service consent. Optional manual jobs remain visible but are not
executed. Report processing and artifact expiry are reported limitations.
A cache key, key.prefix, or fallback_keys entry that still holds a $
after pipeline variables are substituted (for example $$ for a literal $)
fails with GL_VARIABLE_EXPANSION_UNSUPPORTED at that pointer, because the
local runtime cannot expand it.
include: local: is resolved beneath the repository root as GitLab does:
included files merge first, in order, and the including file merges last;
mappings such as variables and default deep-merge while sequences and
scalars are replaced by the later file. Diagnostics inside an included file
name that file's path, line and column. Local wildcard paths, public HTTP(S)
remote sources, file-scoped inputs and ordinary include if/when rules are
supported. Include rules use explicit caller context before target expansion,
reads or input binding; excluded sources contribute no jobs. project, template,
component, include exists/changes and variable addresses remain unsupported.
Selected local paths retain rooted containment and nesting/count/byte bounds.
See includes for
selection order, frontend null/false rules and anonymous remote context.
Hidden keys (names starting with .) are validated only through the jobs that
extends them, matching GitLab. An unreferenced hidden key is ignored whatever
its shape, so YAML anchor lists and other non-job values under hidden keys are
accepted. Diagnostics for inherited fields point at the extending job
(/job/<field>) and carry the template's source line. Extending a hidden key
that is not a mapping is a GL_SOURCE_COMPOSITION error at the extends value.
Root-level image, before_script, after_script and cache are the deprecated GitLab
aliases of the same keys under default:; Loom folds them into default:
before defaults apply, so they behave exactly like default: entries. Defining
the same key at both levels is a GL_SOURCE_COMPOSITION error at the root key.
Root services is still reported as unsupported.
GitLab after_script compiles to finalization commands. Job values replace
inherited commands; [] or null clears them. Like script, commands accept
a string or nested string arrays up to ten array levels, preserving multiline
bytes. References preserve their target shape until the consuming keyword
normalizes it. A main script remains required: a blank scalar or [] is
invalid, while [''] and [[]] are valid. The latter runs setup and finalization
with zero main commands. See
execution behavior.
Rule when follows GitLab's per-scope allowlists. workflow: rules: accept only
always and never; any other value is a GL_SOURCE_TYPE error at the value.
Job rules accept on_success, never, manual and always. A job selected
through when: always runs like on_success, because a local run has no failed
upstream job to run after, and the plan records the non-blocking
GL_LIMIT_WHEN_ALWAYS_NOT_ENFORCED limitation at the rule's when. on_failure
and delayed are reported as GL_SOURCE_UNSUPPORTED until their execution
semantics exist.
Rule if uses the ordinary compile expression contract,
including unary negation, scalar/null/boolean values, operand-valued AND/OR,
reversed/grouped operands and literal or held RE2 patterns. GitLab if lookup
preserves raw winning root/job/caller strings and present-empty values; it does
not recursively expand aliases or pattern contents. Other field consumers keep
their documented expansion phases.
Common schema errors
| Error | Symptom | Fix |
|---|---|---|
| Missing required keys | Errors about missing version, stages, job stage, target, or script | Compare against Syntax (v1) and add the missing keys |
| Type mismatch | expected mapping / expected sequence | Check YAML types — script must be a list of strings, stages must be a list |
| Invalid include path | Errors resolving include.local templates | Ensure includes live under .loom/templates/ with no .. traversal (see Syntax (v1) → include) |
| Cache shape errors | Errors about cache.paths, cache.key, or multi-cache fields | Cross-check Cache and Syntax (v1) → cache |
When reporting a schema validation failure, share the receipt path and a small excerpt. See What to share.
Related commands
loom compile— resolve includes/templates and inspect the compiled graphloom run— execute the workflow after validation passes