Pattern: templates and includes
Compose reusable workflow configuration with include and extends. Templates eliminate copy-paste drift across jobs that share common setup (images, variables, cache config).
For the canonical syntax reference:
include: Syntax (v1) →includeextends: Syntax (v1) →extends- Narrative guide: Includes and templates
Recommended pattern
- Put reusable templates under
.loom/templates/. - Keep template jobs hidden with
.-prefixed names (e.g.,.base,.node-setup). - Extend templates in concrete jobs and override only what changes.
.loom/
workflow.yml
templates/
common.yml # shared base templates
languages/
node.yml # Node.js toolchain defaults
go.yml # Go toolchain defaults
jobs/
lint.yml # reusable lint job templates
test.yml # reusable test job templates
Minimal end-to-end example
A shared template file plus a workflow that includes it and extends a template job.
1) Create a template file
.loom/templates/common.yml:
.base:
image: loom:nix-local
variables:
PNPM_STORE_DIR: .pnpm-store
2) Include and extend in your workflow
.loom/workflow.yml:
version: v1
stages: [ci]
include:
- local: .loom/templates/common.yml
check:
extends: .base
stage: ci
target: linux
script:
- pnpm i --frozen-lockfile
- pnpm run check
The check job inherits image and variables from .base, then adds its own stage, target, and script.
Multi-template composition
Use multiple template files for different concerns. A workflow can include several files, and each job extends whichever template fits.
.loom/templates/languages/node.yml:
.node:
image: node:20-alpine
variables:
PNPM_STORE_DIR: .pnpm-store
NODE_ENV: production
cache:
key:
prefix: pnpm-deps
files: [pnpm-lock.yaml]
paths: [.pnpm-store]
policy: pull-push
when: always
script:
- pnpm install --frozen-lockfile
.loom/templates/languages/go.yml:
.go:
image: golang:1.22
variables:
GOPATH: .go
GOMODCACHE: .go/pkg/mod
cache:
key:
prefix: go-mod
files: ["**/go.sum"]
paths: [.go]
policy: pull-push
when: always
script:
- go mod download
.loom/workflow.yml:
version: v1
stages: [ci]
include:
- local: .loom/templates/languages/node.yml
- local: .loom/templates/languages/go.yml
lint:
extends: .node
stage: ci
target: linux
script:
- pnpm install --frozen-lockfile
- pnpm run lint
go-test:
extends: .go
stage: ci
target: linux
script:
- go mod download
- go test ./...
Each job inherits image, variables, and cache from its language template, then provides its own script.
How merge behavior works
When a child job extends a parent, configuration merges with these rules:
| Value type | Behavior | Example |
|---|---|---|
| Scalars (strings, numbers, booleans) | Child replaces parent | target: linux in child overrides parent |
Sequences (lists like script) | Child replaces parent entirely | script: ["echo child"] replaces parent's script |
Mappings (like variables) | Keys merge recursively — child overrides specific keys, parent provides defaults | Child adds BAR; parent's FOO is preserved |
| Cache | Named caches merge by name; unnamed caches follow mapping/sequence rules | See Workflows → Cache |
Concrete merge example
.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"
Resolved configuration for child:
| Key | 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) |
Key takeaway: variables merge (additive), but script replaces (last wins). If you want to run both parent and child scripts, concatenate them explicitly in the child's script list.
Include rules and constraints
| Constraint | Details |
|---|---|
include must be a YAML sequence | include: [{local: ...}] |
Each entry has only one key: local | Unknown keys are rejected |
Path must start with .loom/templates/ | Other paths are rejected |
Path must end with .yml or .yaml | Other extensions are rejected |
Path must not contain .. | Path traversal is rejected |
| Nested includes are resolved recursively | Cycles are detected and rejected |
Merge order: earlier includes first, then later includes, then the main workflow file. For scalars and sequences, last definition wins. For mappings, keys merge recursively.
Common pitfalls (and fixes)
Include path rejected
Symptom: Schema validation fails with an include path error.
Cause: include.local must start with .loom/templates/, end with .yml/.yaml, and must not contain ...
Fix: Move the file under .loom/templates/ and update the include path.
# Wrong
include:
- local: templates/common.yml
# Correct
include:
- local: .loom/templates/common.yml
Template name doesn't resolve
Symptom: extends fails to resolve.
Cause: extends must point to an existing template job whose name starts with .. The template must be defined in the main file or in an included file.
Fix: Verify the template is defined (check includes) and the name matches exactly.
Cycles in extends
Symptom: Resolution fails with a cycle error like .a -> .b -> .a.
Fix: Keep extends shallow. Extract shared config into a single .base and have jobs extend that directly.
Variables from template are missing
Symptom: A variable you expected from the template isn't set in the job.
Cause: The child job redefines variables as a mapping, which triggers a merge — but if you accidentally set variables to a scalar or sequence, it replaces the parent entirely.
Fix: Always define variables as a mapping (key-value pairs) in both parent and child.
Script from template runs instead of child script
Symptom: The job runs the template's script, not the one you defined in the child.
Cause: You forgot to add a script block in the child job.
Fix: Add script to the child. Sequences (lists) are replaced entirely — the child must provide the complete script.
When not to template
Templates reduce repetition, but they're a tool — not a goal. Avoid them when:
- The "reused" shape only occurs once — copy-paste is simpler and more readable.
- You need 3+ layers of inheritance — deep chains are hard to debug and hard for reviewers to follow.
- The template hides critical behavior — readers must jump between files to understand what runs. If a job's behavior isn't clear from its own definition plus one parent, the template is too abstract.
Debugging templates
Validate structure
Run loom check to catch YAML and schema issues before running:
loom check
Common errors: invalid include paths, missing template references, cycle detection.
Inspect the resolved workflow
Run loom compile to see the fully-resolved workflow after includes are merged, defaults are applied, and extends chains 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).
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 for pointer-first triage.
Related docs
- Includes and templates — full narrative guide with resolution pipeline
- Syntax (v1) →
include— schema reference for includes - Syntax (v1) →
extends— schema reference for extends - Variables — how variables merge across defaults, templates, and jobs
- Cache — cache merge behavior with templates
- Diagnostics ladder — pointer-first failure triage