Skip to main content

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:


  1. Put reusable templates under .loom/templates/.
  2. Keep template jobs hidden with .-prefixed names (e.g., .base, .node-setup).
  3. 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 typeBehaviorExample
Scalars (strings, numbers, booleans)Child replaces parenttarget: linux in child overrides parent
Sequences (lists like script)Child replaces parent entirelyscript: ["echo child"] replaces parent's script
Mappings (like variables)Keys merge recursively — child overrides specific keys, parent provides defaultsChild adds BAR; parent's FOO is preserved
CacheNamed caches merge by name; unnamed caches follow mapping/sequence rulesSee 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:

KeyValueWhy
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

ConstraintDetails
include must be a YAML sequenceinclude: [{local: ...}]
Each entry has only one key: localUnknown keys are rejected
Path must start with .loom/templates/Other paths are rejected
Path must end with .yml or .yamlOther extensions are rejected
Path must not contain ..Path traversal is rejected
Nested includes are resolved recursivelyCycles 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.