Skip to main content

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.

Secrets are available for sensitive values

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​

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:

  • variables must 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 variables and secrets. 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)SourceDescription
1Predefined (system) variablesCI_*, LOOM_*, and other variables resolved from the registry (pipeline, job, and step scopes).
2Workflow variablesSelected YAML root and default variables, below explicit parent/job values.
3Job variablesVariables defined directly on the job override workflow-level values for the same key.
4Caller contextExplicit 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 variables supply fallbacks below default, parent and job variables.
  • inherit.default controls default keywords (including variables), while inherit.variables independently 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:

JobMODESHARED
print-defaultdefaultfrom-default
print-overridejob-overridefrom-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:

  1. Print the variable from your script: printf '%s\n' "$MY_VAR"
  2. Read loom logs <run_id> --json and match the command's job_id, step_id and command_id in stdout events. MCP loom_read_events can filter by job_id and stream.
  3. 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:

  1. Validate schema first: run loom check to rule out invalid names or value types.
  2. 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_* or LOOM_* variable shadowing your key?
  3. Confirm provider routing:
    • No image: → Host provider (inherits host env)
    • image: set → Docker provider (isolated env)
  4. Prove the runtime value: print the variable from the job script, then match its IDs in the run's events.jsonl using the Diagnostics ladder.
  5. 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).