Includes and templates
Split large workflows into reusable pieces. Includes import template files into your workflow at parse time. Templates (extends) let jobs inherit configuration from hidden or executable job definitions, reducing duplication across similar jobs.
include:
- local: ci/shared/common.yml
.node-base:
image: node:20
variables:
NODE_ENV: production
lint:
extends: .node-base
stage: ci
target: linux
script:
- npm run lint
Resolution pipeline
Loom resolves includes and templates in a fixed order before validation:
1. Parse the main workflow and file-local YAML anchors
2. Validate include rules, select sources, resolve includes recursively, merge them in declaration order, then merge the including file
3. Resolve named command fragments against the merged catalog
4. Resolve ordered extends parents, then merge each child
5. Apply each job's selected defaults
6. Apply its selected root variables, below parent/default/job variables
7. Validate the fully resolved workflow
Composition validates the complete source before requested-job selection. Hidden parents and unused fragments cannot hide malformed composition. Native maps merge recursively, with the cache and services exceptions below; GitLab retains its own include, reference and whole-keyword default semantics.
Includes (include.local)
Use include to import local YAML template files into your workflow.
Syntax
include:
- local: ci/shared/common.yml
- local: tooling/jobs/node.yaml
Rules
| Constraint | Enforcement |
|---|---|
include must be a YAML sequence | Schema error if not a sequence |
Local entries use local, optional rules and file-scoped inputs | Unknown keys are rejected |
| Path must be normalized and relative to the repository root | Rejected before content is read |
Literal paths must end with .yml or .yaml; star patterns may use other suffixes | Matched files are parsed as YAML |
Absolute paths and any .. segment are forbidden | Path traversal is rejected |
| Target must be a regular file beneath the rooted reader | Links, directories, and special files are checked safely |
| Cycles are detected | Error shows the full include chain |
Both native and GitLab compilations accept up to 150 selected include occurrences, excluding the root workflow. Repeated literals, nested includes and each wildcard match count separately across the whole compilation, including supported remote and mixed trees. Filtered entries and zero-match patterns count zero. There is no separate numeric nesting limit: the cumulative count bounds include recursion to 150 edges. The current-parent wildcard skip and permitted top-level root-once match remain unchanged; an included occurrence of that root counts one.
This is GitLab 19.5's released default count profile, explicitly adopted for native workflows; a GitLab instance can configure a different maximum. Independent Loom safety guards still apply and can reject larger configurations before 150 includes. Native's 4 MiB cumulative YAML byte budget includes the root and every repeated include. GitLab's 4 MiB include-byte counter excludes the root, and GitLab sources retain a separate 1 MiB per-file limit. Raw byte accounting does not establish GitLab's allocation-size equivalence. The shared 100,000-node YAML materialization budget also charges concrete composition copies, including fragment and parent expansion. Limit, cycle, work and cancellation failures produce no executable partial graph. Native limit errors retain the safe repository-relative include chain; both frontends retain the declaring include location.
Conditional includes
Both native and GitLab include mappings accept ordered rules with ordinary
if expressions and when: always or when: never. For example:
include:
- local: ci/producer.yml
rules: [{ if: '$INCLUDE_PRODUCER == "yes"' }]
Pass INCLUDE_PRODUCER in the explicit CLI --context file or MCP variable
context. Rules see that caller view before composition; variables authored in
root/default/job mappings or earlier included files cannot change include selection.
Native caller aliases are resolved with native scalar policy; GitLab rule aliases
remain raw. Undefined values are distinct from defined empty strings.
The first matching rule wins; omitted when includes, never excludes, and no
match excludes. No rules includes unconditionally. Native null clears rules;
false and empty lists/maps are invalid. GitLab absent/null/false rules bypass
selection, while empty lists/maps and empty individual rules are invalid;
GitLab null if/when fields are omitted.
All outer rules validate before loading any selected target. Excluded targets do not expand wildcards, read bytes, bind target inputs or consume include occurrences. Selected missing/invalid sources still fail, including before requested-job pruning. GitLab optional needs for an excluded producer are omitted without adding fallback stage barriers. Native optional needs are a separate contract.
The paired composition corpus provides include/exclude caller files and an
included conditional job that publishes exactly included\n in include.txt.
Its gitlab-conditional-needs.yml companion adds a five-second unrelated build
job and an optional-needs consumer. The consumer publishes selection.txt with
exactly included\n when include.txt is restored, or excluded\n when the
producer is omitted. With parallel execution, the omitted-producer consumer has
no prior-stage dependency.
exists, changes, variable addresses and complete phase/provenance support
remain required future work; ordinary caller predicates do not establish full
GitLab include parity.
Public remote sources
Native include mappings also accept remote, optional integrity, cache and
inputs. GitLab accepts its include:remote mapping or URL shorthand. Each
frontend retains its own ordered merge and default semantics.
include:
- remote: https://gitlab.com/beepbeepgo/loom-build/loom/-/raw/dbc24d2261bb4ae8b5fb2c92e48b70d37ad5aa17/.loom/conformance/configuration-inputs/native-unit.yml
integrity: sha256-N96FIE3l2DOuNNQqd6l/kCEJFCwSK+glR2mY31qKuVE=
cache: true
inputs:
prefix: left
Use a revision-bound URL and the SHA256 of its original response bytes when
pinning a dependency. Integrity is optional and never inherited by nested
sources. The plan's sources closure records every selected requested/final
address identity, query-free origin, content digest and byte count. Original
queries and credentials are not projected into diagnostics or source locations.
The separate configuration identity records each file's independently bound
input invocation, including repeated includes of the same URL.
Remote includes use anonymous GETs with TLS verification and public destination checks. They do not consume Git credentials, cookies or ambient authorization. Nested includes have no caller repository or include-variable context: forward inputs explicitly, use another public remote URL, and do not use a local include to read caller files. Variable URLs, project/template/component retrieval and advanced module behavior remain unsupported here.
A compile/check operation selects each effective URL's bytes once, while every include occurrence still verifies integrity and binds inputs independently. A run pins those same bytes during preflight, then recompiles local sources from its isolated snapshot. Snapshot compilation cannot fetch a newly introduced URL. Remote bodies are held privately for that operation; they are not added to job workspaces or retained as a replay bundle. The final graph or terminal preflight records hash-only source-closure evidence in the existing events file.
Disk reuse is opt-in: cache: true means one hour, and a duration such as
2 hours 30 minutes or 1:30 must be at least one minute. Omitted/null/false,
blank strings and empty collections bypass reuse. The cache stores original
source bytes under .loom/.runtime/source-cache/v1/, independently of job cache
archives and run evidence. Warm reads recheck the stored digest and declared
integrity; expired/corrupt entries fetch again. No input values or interpolated
trees are cached, and cache hits do not extend expiry.
Retrieval has bounded redirects, attempts, input bytes and phase timeouts. Caller cancellation interrupts network work and retry delays; the configured fetch deadline is checked at phase boundaries rather than promising a hard 30-second total runtime. The selected-occurrence and aggregate YAML budgets also charge remote sources and repeated invocations. GitLab sources retain their one-MiB per-file bound. Failed retrieval, integrity, parsing or binding returns no executable graph.
The maintained remote corpus contains public revision-bound native host and GitLab Docker roots, repeated typed inputs and exact expected artifact bytes.
Local star patterns
include:
- local: "ci/**/*.yml"
- local: "optional/*.yml"
* matches characters within a path segment; ** also crosses directories,
and **/ allows directories or the repository root. Dotfiles are ordinary
matches. Other punctuation such as ?, brackets and braces stays literal.
Only a star activates expansion. Native patterns remain normalized
repository-relative paths without traversal; unlike native literal paths they
need no YAML suffix. GitLab removes leading slashes from local paths and keeps
all other authored bytes, including spaces, in both literal paths and patterns.
For example, the quoted pattern "catalog/*.yml " matches names ending in a space, while
catalog//*.yml does not match canonical catalog/part.yml. Enumeration uses
actual directory-entry names regardless of authored slash/dot spelling. Every
matched source is parsed as YAML. Matches follow Git tree order, include entries
keep declaration order, and the including file wins last. No match adds nothing;
a missing literal file remains an error. Repeated/overlapping patterns still
count every include occurrence.
In Git repositories, discovery uses the same selected source paths and bytes as local-run snapshots: tracked changes and eligible untracked files are included; ignored runtime/dependency files are excluded. A retained HEAD source can be read instead of an ignored recreated host file. Selected links resolve within that view and preserve containment and cycle identity for native includes. GitLab local includes instead parse the selected entry bytes: a symbolic link contributes its target text as YAML without following the target. Ordinary path text fails the included-mapping check; valid YAML target text can compose even for a dangling link. Symlink directories do not create repository entry children. Valid repositories before their first commit support check/compile; local runs still require committed HEAD. Non-Git library roots use bounded filesystem discovery without Git-ignore or run-snapshot equivalence. Explicit native authorized external/literal reads and root-file admission keep their existing boundary and do not add external files to a run snapshot. Wildcard matches stay within the repository even when an external root is authorized for explicit literal/root reads. GitLab selected link text remains entry data and is not dereferenced.
Each source-admission or compile operation creates one catalog and Git Status scan. Chained API calls have separate lifetimes: CLI compile typically uses two scans, while check can use three. Patterns and nested includes reuse the compiler catalog without additional scans. Existing synchronous Git operations have no interruption deadline or atomic concurrent-write guarantee.
A wildcard skips the current included local parent. At the top level, a pattern
may include the root once; inside that included root its own match is skipped.
Literal self-includes still fail as cycles. Pattern discovery has a compile-local
64 Mi-unit work guard, including cached-prefix selection and sorted-range
comparisons, and generated filters have a 1000-byte bound after quoting
and star replacement. Existing include/node limits continue to apply; these are
not full GitLab limit-equivalence claims. In the star language, * can match a
newline within a filename, while the dot expression used by ** cannot.
Nested includes
Included files can themselves contain include entries. Loom resolves them recursively. Cycles are detected and rejected with a descriptive error showing the full chain:
resolve include cycle by removing one include edge:
.loom/workflow.yml -> ci/a.yml -> tooling/b.yaml -> ci/a.yml
Merge order
Included files are merged in declaration order: earlier includes first, then later includes, then the main workflow file. When the same key appears in multiple files:
- Scalars and sequences (strings, numbers, lists): the last definition wins.
- Mappings (like
variablesand partial job definitions): keys merge recursively — later files override specific keys while preserving others. - Job/default cache: mappings merge recursively; cache lists merge by cache name, retaining unmatched entries.
nullor[]clears all caches; a nameddisabled: trueentry removes that inherited cache. - Job/default services: the later value replaces the entire services declaration.
services: []clears inherited services.
The cache and services policies apply at job/default keywords. A nested mapping inside another field uses ordinary map merging, even if a key has the same name.
Recommended directory structure
Keep includes small, composable, and organized by intent:
ci/
shared/
common.yml # shared defaults
tooling/
jobs/
node.yaml # Node.js toolchain setup
go.yml # Go toolchain setup
.loom/
workflow.yml # root workflow
Name templates by what they do (tooling/jobs/node.yaml, ci/shared/lint.yml) rather than by team or project names. Prefer several small files over one large include — this makes templates easier to find, review, and compose.
Why rooted path checks exist
include.local accepts YAML files from any directory in the repository. Loom resolves and
opens every root and nested include through the repository's rooted reader. It rejects
absolute paths, traversal, escaping or dangling links, directories, special files, and
in-flight replacement outside the root before consuming their content. These checks keep includes:
- Local and reviewable — all included files remain inside the repository root.
- Harder to smuggle — files from unrelated directories cannot be pulled in accidentally.
- Auditable — diagnostics and cycle chains use repository-relative paths without exposing host paths.
To use a template from outside the repo, vendor it into the repository and review it like any other code change.
Templates (extends)
A job whose name starts with . is a template job. Templates are not executed
directly. Both templates and executable jobs can supply configuration via
extends; an executable parent retains its own job identity and runs according
to ordinary planning rules.
Syntax
.base:
image: loom:nix-local
variables:
PNPM_STORE_DIR: .pnpm-store
docs-lint:
extends: .base
stage: ci
target: linux
script:
- pnpm i --frozen-lockfile
- pnpm nx run loom-platform:docs-mdlint
Rules
| Constraint | Enforcement |
|---|---|
extends is a job name or ordered list of names | Later parents override earlier parents; the child overrides all parents |
| Each parent must exist and be a mapping | Hidden and executable parents are accepted |
extends: null or extends: [] | Clears an include-provided parent declaration |
| False, empty names and malformed list items | Rejected at the authored field/item |
| Cycles | Rejected with the ordered parent chain |
Merge semantics
When a child job extends a parent template, configuration is merged with these rules:
| Value type | Behavior | Example |
|---|---|---|
| Scalars (strings, numbers, booleans) | Child replaces parent | target: linux in child overrides parent |
| Sequences (lists) | Child replaces parent entirely | script: ["echo child"] replaces parent's script |
Mappings (like variables) | Keys merge recursively — child overrides, parent provides defaults | Child adds BAR; parent's FOO is preserved |
| Cache | Special merge — named caches merge by name, unnamed caches follow mapping/sequence rules | See Cache |
Example showing merge behavior:
.base:
variables:
FOO: "from-base"
BAR: "from-base"
script:
- echo "base"
child:
extends: .base
stage: ci
target: linux
variables:
BAR: "from-child"
NEW: "child-only"
script:
- echo "child"
Effective resolved configuration for child:
| Key | Resolved value | Why |
|---|---|---|
variables.FOO | "from-base" | Inherited from parent (mapping merge) |
variables.BAR | "from-child" | Child overrides parent (mapping merge) |
variables.NEW | "child-only" | Added by child |
script | ["echo child"] | Child replaces parent (sequence replacement) |
Template requirements
Hidden templates may contain configuration only; they do not need an invented script. Every authored field still follows its validator. Each resolved executable job needs a valid stage, target and nonempty script.
Command fragments
Define reusable command values under root fragments. Use exactly
{fragment: name} in a command position. Names are nonempty strings without
whitespace or control characters. Definitions may use other fragments, including
from included files. Fragments produce no graph nodes.
fragments:
prepare: [echo prepare]
verify: [{ fragment: prepare }, [echo verify]]
check:
stage: ci
target: linux
script: [{ fragment: verify }, echo check]
Commands accept a nonempty string, nested command lists (up to ten array levels), or a fragment use. Flattening preserves command order and YAML-decoded bytes, including block-scalar newlines. A fragment chain is not an independent ten-hop limit; all copies consume the compilation's node budget. Missing names, cycles, null/boolean/numeric leaves, extra use-map keys and malformed unused definitions fail compilation. Diagnostics retain the definition and invoking source context.
Arrays still replace during inheritance. To append commands, write the desired
fragment uses and additional commands in the child's sequence explicitly.
before_script: null or [] and after_script: null or [] clear those optional
phases. script: null and script: [] remain invalid.
Select inherited defaults and root variables
variables: { ROOT: root, OMIT: omitted }
default:
target: linux
before_script: [echo setup]
variables: { DEFAULT: retained }
check:
stage: ci
script: echo check
inherit:
default: [target, variables]
variables: [ROOT]
inherit accepts only default and variables. Each control is true (all),
false (none), or a list of names; [] selects none. Omitted or null controls
select all. Default names must belong to the native default allowlist.
Default variables and root variables are controlled separately. The example
keeps DEFAULT and ROOT, and omits the default setup command and OMIT.
The control follows ordinary parent merging. It filters YAML defaults before injection and preserves parent-resolved/job explicit values. It does not filter caller variables, secrets or runtime context. Native defaults keep native merge semantics; a GitLab whole-keyword default is a different source contract.
Debugging resolution
Step 1: Validate structure
Run loom check to validate the composed workflow and report source errors:
loom check
Common errors at this stage:
- Invalid
include.localpath (absolute, unnormalized, missing extension, or path traversal). - Missing template reference (
extendspoints to a name that doesn't exist). - Include cycle or extends cycle.
Step 2: Inspect the resolved workflow
Run loom compile to inspect the graph after includes, fragments, ordered parents
and selected defaults/root variables are resolved:
loom compile --workflow .loom/workflow.yml
This shows the effective configuration each job will run with. Use it to verify that template merging produced the expected result — especially for variables (mapping merge) and script (sequence replacement).
Step 3: Diagnose runtime failures
If the workflow resolves and validates but a job fails at runtime, the issue is in execution, not resolution. Use the diagnostics ladder:
- Diagnostics ladder — pointer-first failure triage.
- Common failures — frequent issues and fixes.
Planned
- Include
exists/changespredicates and variable addresses. - Project/template/component retrieval and complete include-phase provenance.
Next steps
- Syntax (v1) →
include— canonical schema reference for includes - Syntax (v1) →
extends— canonical schema reference for extends - Variables — how variables merge across defaults, templates, and jobs
- Cache — cache merge behavior with templates