Variables
Variables are key/value string pairs attached to a workflow or a job. They are the primary mechanism to parameterize scripts without duplicating jobs.
For predefined variables that Loom injects into every job environment (including CI_* and LOOM_*), see Predefined CI/CD variables (Loom).
If you're looking for the broader conceptual model and common pitfalls, see Concepts → Variables.
If a value is sensitive (password, token, private key), use Secrets instead of variables. secrets entries store references, inject via file path by default, and redact exact matches to resolved values in supported console, state and event output. Transformed values and raw declared artifacts remain outside that protection, so inspect evidence before sharing it. See Concepts → Secrets for the mental model, or Migrating from variables to secrets for a step-by-step guide.
Syntax
Variables can be defined at three levels in the workflow YAML. For canonical schema anchors, see:
- Workflow-level
variables: Syntax (v1) →variables - Default
variables: Syntax (v1) →default - Job
variables: Syntax (v1) → Jobvariables
Workflow-level variables
variables:
PNPM_STORE_DIR: .pnpm-store
NODE_ENV: production
Default variables
default:
variables:
PNPM_STORE_DIR: .pnpm-store
Job-level variables
check:
stage: ci
target: linux
variables:
PNPM_STORE_DIR: .pnpm-store
script:
- echo "$PNPM_STORE_DIR"
Schema rules
The schema validator enforces:
variablesmust be a YAML mapping.- Variable names must match
^[A-Z_][A-Z0-9_]*$(uppercase letters, digits, underscores; must start with a letter or underscore). - Values are bare YAML scalars or rich definitions. Bare scalars keep their authored text, including numbers, booleans and null spellings. Lists and unknown rich fields fail validation.
- A job cannot define the same key in both
variablesandsecrets. See Syntax (v1) →secrets.
Precedence
Variables are merged before the job executes. The merge applies in layers, where later layers override earlier ones for the same key:
| Priority (lowest → highest) | Source | Description |
|---|---|---|
| 1 | Predefined (system) variables | CI_*, LOOM_*, and other variables resolved from the registry (pipeline, job, and step scopes). |
| 2 | Workflow variables | Selected YAML root and default variables, below explicit parent/job values. |
| 3 | Job variables | Variables defined directly on the job override workflow-level values for the same key. |
| 4 | Caller context | Explicit caller values override composed YAML, including empty strings. |
Within the YAML composition layers:
- Includes merge first; ordered parents resolve before selected native defaults.
- Selected root
variablessupply fallbacks below default, parent and job variables. inherit.defaultcontrols default keywords (includingvariables), whileinherit.variablesindependently controls YAML root variables. Explicit parent/job values survive. See inheritance filters.- Rich mapping patches retain omitted fields through includes, parents and native root/default/job fallback; scalar and caller replacements replace the entire definition. Dependencies resolve after these winners and caller context are known.
Merge example
version: v1
stages: [ci]
default:
target: linux
variables:
MODE: default
SHARED: from-default
print-default:
stage: ci
script:
- printf '%s\n' "$MODE" "$SHARED"
print-override:
stage: ci
variables:
MODE: job-override
script:
- printf '%s\n' "$MODE" "$SHARED"
Final values:
| Job | MODE | SHARED |
|---|---|---|
print-default | default | from-default |
print-override | job-override | from-default |
print-override overrides MODE via its own variables but inherits SHARED from the default.
Rich values and literal expansion
Use the same rich definition at native root, default and job locations:
variables:
BASE: normal
RAW: { value: "raw-$BASE", expand: false }
COPY: "$RAW"
MODE: { value: development, description: Build mode, options: [development, production] }
RAW and COPY both contain exactly raw-$BASE. Literal values have no
variable dependencies: raw self references and raw cycles remain text. Ordinary
expandable cycles still fail. expand: null also preserves literal bytes;
omitted/true expands normally. Unknown fields and malformed metadata fail before
an explicit caller override can conceal them. Missing/null rich value gives an
empty string; bare native null and ~ retain their authored text.
description accepts string/integer/null. options accepts a string sequence or
null; a nonempty list must contain the authored native default text. Caller
strings, including empty or values outside options, replace the entire definition
and follow normal caller expression handling. These metadata fields do not add a
prefill UI or runtime restriction.
A mapping patch such as {value: 'new-$BASE'} retains an inherited expand:false
and metadata. {} retains fields too. A scalar replacement drops inherited
metadata/mode; explicit expand:true changes the mode. Native root/default/job
fallback uses this recursive mapping merge. GitLab root-to-job overlay instead
replaces the whole record, while its includes/extends still merge mapping fields.
GitLab permits metadata only at root; job definitions allow value/expand, and
default:variables is invalid. Bare null is invalid there; scalar values use
released Ruby string conversion, while rich missing/null becomes empty. Options
check the original authored type: numeric value: 1 does not match string option
'1'. Plain case-insensitive yes/true/on and no/false/off are authored booleans;
quoted or typed input strings keep their string type. Thus expand: 'false' is
invalid, while a typed boolean is valid. Native boolean spelling is unchanged. Released GitLab authored scalar typing also
keeps 09 and 1e3 as strings, preserves integer precision and converts numeric
values with Ruby formatting. Authored symbols admitted by YAML, such as a block
value: :foo, keep Symbol type through metadata validation and become foo via
string conversion; a string option does not match that Symbol default. Quoted or
typed string ':foo' keeps its colon. Date/time scalar classes are not admitted.
GitLab's environment uses server dependency expansion then one Runner pass.
Matched job-rule variables
Native job selection.rules[].variables and GitLab job rules:variables apply
only the first matching automatic job rule. Conditions use the pre-action view;
the action then replaces whole variable definitions above composed job values and
below explicit caller values, including empty strings. Dependent aliases and
supported cache keys use the selected winners. Scalar action replacement resets
an inherited raw mode; native actions may explicitly choose a new raw definition.
Native actions use the native scalar/rich mapping grammar above. GitLab actions accept only non-null scalar values: strings, symbols, integers, floats and booleans, converted using the released scalar rules. Rich mappings are invalid there. GitLab omitted/null/empty assignment maps are no-ops; native null maps are invalid. A no-op first match still stops later rules. Invalid actions in later rules or requested-away visible jobs cannot be hidden by caller overrides. An unused GitLab hidden template has no nested rule-field validation; its action validates when inherited into a visible job.
Assignments cannot change workflow/include selection, typed inputs, fanout job
identities or dependencies. Workflow-rule variable actions remain unsupported.
Ordinary references to raw values defer until that pass, preserving inserted
bytes. Rules still read authored values. Cache fields retain their own consumed
phase: main key/files prefix receives one Runner pass; explicit fallback keys
receive two. With RAW='raw-$BASE', BASE=normal, a key $RAW keeps
raw-$BASE, while a GitLab fallback $RAW becomes raw-normal. Literal final
cache bytes use existing neutral bindings and are not parsed again at runtime.
Scalar references and caller values
Native scalar values recursively expand $NAME and ${NAME} with ASCII
identifier names; forward dependencies are valid. For example, ALIAS: '$BASE'
uses the effective root/job/caller BASE. Cycles fail check and compilation,
including unscoped checks without context. Unknown ordinary references become
empty; missing predefined runtime facts bind after provider assignment.
$$ escapes a literal dollar: LITERAL: '$$BASE' yields $BASE. An alias of
LITERAL retains those literal bytes. Percent tokens and shell expressions such
as ${NAME:-fallback} or $(command) stay literal in native scalar values.
Scalar binding does not rewrite scripts or execute expressions.
Supply native caller values with the strict
caller context envelope. GitLab uses its
separate context and released
two-phase behavior: known $NAME, ${NAME} and %NAME% dependencies expand on
the server, then Runner applies one dollar expansion pass. Unknown percent tokens
stay literal; unknown dollar lookups become empty. Escaped dollar payloads are
not rescanned after insertion. Rules and cache fields keep their own supported
expansion phases; this does not imply every workflow field accepts scalar syntax.
Explicit exported directory values, including empty values, win over runtime defaults. They do not move the physical cwd or change mounts and cleanup.
For source-admitted names representable in a process environment, runtime overrides match the exact name and retain explicit empty values. Whitespace in an admitted name does not replace a different, unspaced name. Host and Docker process environments serialize explicit names without trimming them; shells have their own rules for importing names and referring to them in commands. Native caller key normalization continues to follow the variable context contract.
Process admission rejects names containing = or NUL and final values containing
NUL. Docker also rejects empty names and checks service environments. Host
execution preserves admitted empty names. Value checks respect secret precedence:
a valid environment secret or file-secret path can replace an ordinary value
containing NUL. NUL inside a secret file's contents is separate from its exported
path. Rejections identify the entry's position and reason without exposing its
name or value. Source validation and compilation can accept inputs that fail this
runtime boundary.
Provider behavior
Variables interact with providers in ways that matter for reproducibility:
Host provider
When no image: is set, the job runs on the host shell. The host provider inherits the environment of the loom run --local process, then applies workflow and job variables on top.
Recommendation: don't rely on shell environment variables that happen to be set on your machine. Declare required non-sensitive values in workflow or job variables to reduce environment differences between machines and CI.
See Host provider → Runtime behavior.
Docker provider
When image: is set, the job runs inside a container. The Docker provider passes merged variables into the container environment. Docker jobs receive declared and predefined Loom variables plus image defaults. They do not inherit the host environment. Scalar references use the same explicit
variable lookup as host jobs; they never consult ambient host variables.
Recommendation: treat Docker jobs as isolated environments. Assume only variables declared in the workflow are available.
See Docker provider → Runtime behavior.
How to confirm the final value
For a non-sensitive variable, capture its runtime value and inspect the corresponding event stream:
- Print the variable from your script:
printf '%s\n' "$MY_VAR" - Read
loom logs <run_id> --jsonand match the command'sjob_id,step_idandcommand_idin stdout events. MCPloom_read_eventscan filter byjob_idandstream. - If sharing for support, include the run ID and a short reviewed state/event excerpt and follow What to share.
Troubleshooting: precedence surprises
Use this checklist when a job sees an unexpected variable value:
- Validate schema first: run
loom checkto rule out invalid names or value types. - Identify the layer:
- Is the variable set in
default.variables? - Is it overridden in job
variables? - Is it coming from the host environment via the Host provider?
- Is a predefined
CI_*orLOOM_*variable shadowing your key?
- Is the variable set in
- Confirm provider routing:
- No
image:→ Host provider (inherits host env) image:set → Docker provider (isolated env)
- No
- Prove the runtime value: print the variable from the job script, then match its IDs in the run's
events.jsonlusing the Diagnostics ladder. - Share minimal evidence: follow What to share — run ID, operation IDs, state and a small event excerpt.
Planned
- Typed, raw and file-valued variables.
- Variable expansion in non-script contexts (e.g.
image, cache keys).