Variables & Secrets
Jobs receive variables from multiple sources, layered by precedence (lowest to highest):
| Priority | Source | How to set |
|---|---|---|
| 1 (lowest) | CI-derived | Automatic (git SHA, branch, etc.) |
| 2 | Global YAML | variables: at top of .gitlab-ci.yml, plus a matched workflow: rules: variables: which overrides it |
| 3 | Instance variables | Fetched from GitLab API (admin token; --secrets all) |
| 4 | Group variables | Fetched from GitLab API (--secrets all) |
| 5 | Project variables | Fetched from GitLab API |
| 6 | --env flags | glci run --env KEY=VALUE |
| 7 | --env-file | glci run --env-file .env.local |
| 8 | Pipeline preset env | env of a --pipeline/context preset in .glciconfig.toml |
| 9 | .glci.env | Auto-loaded from project root (add to .gitignore) |
| 10 | Dotenv artifacts | artifacts: reports: dotenv: from dependency jobs |
| 11 (highest) | Job YAML | variables: inside a job definition |
Workflow rule variables share the global layer because that is where GitLab puts them: the matched rule merges into the pipeline’s root variables, so everything from row 3 down still overrides them.
This mirrors GitLab’s order: instance, group, and project CI/CD variables override the global variables: block, with the more specific scope (project) winning over the broader ones (group, instance).
The table describes the environment a job runs with. What a rules: condition sees is ordered differently — see Variables in scope for rules:.
Variables in scope for rules:#
A job’s rules: are evaluated against the job’s own scope, not just the pipeline-wide set. That means an if: can test the job’s own variables: block and the values injected by parallel: matrix:, the same as on GitLab:
deploystacks:
script: ./deploy.sh
parallel:
matrix:
- PROVIDER: aws
SKIP: "false"
- PROVIDER: ovh
SKIP: "true"
rules:
- if: '$SKIP == "false"' # only the aws shard is created
Matrix expansion happens before rules are evaluated, so each shard is judged against its own values — using a matrix variable in rules:if: is the documented way to include or exclude individual shards.
The job’s own values do not win outright. GitLab ranks a job’s variables: below everything a person supplied for the run, and glci matches that when evaluating rules. Anything from rows 3-9 of the table above — instance, group and project CI/CD variables, --env, --env-file, a preset’s env, .glci.env — decides a rule even where the job declares the same name:
$ glci jobs --env DEPLOY_ENV=staging # a job's own DEPLOY_ENV does not override this
What the job’s variables: block does decide is every key nothing above it defines, which is the common case and the one this scope exists for.
Inheriting a global is not redeclaring it. glci folds the global variables: block into every job, and those inherited entries keep global precedence rather than being promoted. Only a key the job sets to a value that differs from the global counts as the job’s own — redeclaring a key with the identical value is indistinguishable from inheriting it, so it is treated as inherited, and the same is true of a matrix: value that happens to equal a same-named global.
Difference from GitLab: this ordering applies to rule evaluation only. The environment a job actually runs with still gives its
variables:block the last word (row 11 of the table), so for a key that both a job and a higher layer define, the rule is decided on one value and the script sees another. GitLab ranks job YAML below those layers in both. Aligning the executed environment is tracked separately.
The three local-resolution commands never fetch API variables, so glci jobs, glci show and glci variables cannot show you a rule that an instance, group or project variable would decide differently on a real run.
Inspecting resolved variables#
When a value isn’t what you expect — or a rules: clause matches when it shouldn’t (or doesn’t when it should) — inspect the resolved variables instead of guessing.
glci variables — static inspection#
glci variables resolves the variables for every job without running the pipeline, attributing each value to the layer it came from and showing how each job’s rules: evaluated. Because nothing executes, jobs that are excluded by their rules: are still shown (marked excluded), which is the main way to debug why a rule did or did not match.
It focuses on the variables you actually control. Predefined CI_* variables are hidden by default (pass --all to include them), with one exception: any predefined variable referenced by a rule’s if: is always shown, so you can see the value the rule was evaluated against.
glci variables # all jobs, current context
glci variables deploy_prod # only the named job(s)
glci variables --stage test # only jobs in a stage
glci variables --context branch=main # simulate a different context
glci variables --all # include predefined CI_* variables
glci variables --json # machine-readable output
Example:
deploy_prod (stage: deploy) [included, when: on_success]
Rules:
#0 $CI_COMMIT_BRANCH (main) == $CI_DEFAULT_BRANCH (main) → true
Variables:
KEY VALUE SOURCE
CI_COMMIT_BRANCH main predefined
DEPLOY_ENV production rules
GREETING hello global
The Rules section shows each rule’s condition with every $VAR expanded to its value, and whether the condition evaluated true or false (a failing changes:/exists: rule appends a short reason). The Variables SOURCE column reports where the winning value came from: predefined, global, flag (--env), env-file, glci-env, preset, job, rules, or predefined:job (per-job CI_JOB_NAME/CI_JOB_STAGE).
A job excluded by its rules still prints, so you can see why:
only_dev (stage: test) [excluded, when: never]
Rules:
#0 $CI_COMMIT_BRANCH (main) == "dev" → false
Variables:
KEY VALUE SOURCE
CI_COMMIT_BRANCH main predefined
...
With --json, each job carries a rule_trace (with matched booleans and the refs map of referenced variable values) and the full variables array — suitable for programmatic use.
Long values (e.g. CI_COMMIT_DESCRIPTION) and multi-line values are truncated to 512 characters and shown on a single line so the table stays readable. Pass --expand to print full, raw values. (--json always carries the full value.)
glci variables resolves everything available locally (no Docker, no network). It does not fetch GitLab API group/project secrets or runtime values (dotenv, CI_JOB_ID); for those, use --show-variables below.
glci run --show-variables#
Adding --show-variables to glci run prints each job’s fully resolved variable set — including API secrets, dotenv imports, and runtime CI_* values — together with its rule trace, to the terminal just before the job’s log:
glci run --show-variables # secrets masked, predefined CI_* hidden
glci run --show-variables --unmask # reveal masked/secret values
glci run --show-variables --all-variables # include predefined CI_* variables
glci run --show-variables --expand # do not truncate long values
deploy ── variables ──
Rules:
#0 $CI_COMMIT_BRANCH (main) == $CI_DEFAULT_BRANCH (main) → true
Variables:
KEY VALUE SOURCE
CI_COMMIT_BRANCH main predefined
CI_REGISTRY_PASSWORD [masked] registry
DEPLOY_ENV production rules
Like glci variables, predefined CI_* variables are hidden by default (use --all-variables), except those a rule references. Secret values (API group/project variables and any variable marked masked) are shown as [masked] by default; pass --unmask to print them in clear text.
Rule evaluation never quotes a value#
Rules are evaluated against expanded values, so an error raised while evaluating one could name a value rather than the $VAR that stood for it. None does. When a variable used as the right-hand side of =~ or !~ does not compile as a regular expression, glci names the variable and reports the regex error alone:
#0 $CI_COMMIT_BRANCH (main) =~ $DEPLOY_PATTERN (+badpattern) → false (if: could not be evaluated: invalid regex in $DEPLOY_PATTERN: missing argument to repetition operator)
The value is shown here as (+badpattern) because this one came from --env and is not secret — that annotation is masked on its own terms, exactly as it is in the table below it. The reason string is what changed: it never carries the pattern, whether or not the variable is masked, and regardless of --mask.
The same message reaches every consumer of the trace: the glci variables table, glci run --show-variables, the --json form of either, the glci: warning: line any config-resolving command writes to your terminal, and the copy of that warning in ~/.glci/daemon.log on glci run. The if: expression printed beside it is always the source text, where a variable appears as its name.
A regex literal is not a value, so it is reported as invalid regex: missing closing ] without being repeated — it is already on screen in the expression next to the message.
The regex error code is kept on purpose. It narrows the failure to one of a fixed set — missing closing ], trailing backslash, and so on — which says a little about the value’s shape and nothing about its contents. Dropping it would cost you the reason the rule failed and buy very little.
In the rule trace, a shadowed secret is masked by its value, not merely by its name. A masked value stays [masked] there even when a later layer supplies something else under the same name — a job’s own variables: or a dotenv artifact shadowing a masked project variable, say. Neither of those layers exists yet when rules: are evaluated, so the value the trace tested really is the masked one. A variable whose own winning layer is secret is masked whatever value is shown for it.
A value you supplied yourself is still shown. --env, --env-file, preset env: and .glci.env all outrank the API layers and are part of what rules are evaluated against, so glci run --show-variables --env TOKEN=local-dev prints $TOKEN (local-dev) even when the project defines a masked TOKEN — that is what the rule was tested against, and hiding it would defeat the point of the trace. The trace and the variables table can still disagree, because the table shows the winning layer on its own terms: with a job-level TOKEN: placeholder shadowing a masked project variable, the table reads placeholder where the trace above it reads [masked].
Remote secrets#
Project, group, and instance CI/CD variables are fetched automatically when a GitLab token is available. Results are cached in daemon memory for 6 hours (never written to disk). The cache is lost on daemon restart.
glci run --secrets all # project + group + instance variables (default)
glci run --secrets project # project variables only
glci run --secrets none # skip remote fetching entirely
glci run --refresh-secrets # force a fresh fetch, bypassing the cache
Instance-level variables (GET /admin/ci/variables) require an administrator token. For non-admin tokens GitLab returns 403, and glci skips them with a warning in the daemon log — project and group variables are unaffected. This matches how real runners receive instance variables injected server-side; locally, glci can only read them when your token is allowed to. If your includes or jobs depend on an instance variable you can’t fetch (for example a non-admin token on a shared instance), supply it locally instead via .glci.env, --env, or a pipeline preset.
Configure the cache TTL in .glciconfig.toml:
[gitlab]
secrets_ttl = "6h" # default; set to "0" to disable caching
Local overrides with .glci.env#
Auto-loaded KEY=VALUE file from project root for local secrets. Add it to .gitignore. Masked and file-type variable attributes from the GitLab API are preserved and passed to the runner. Values from this file are redacted out of include-resolution output on the same terms as any other local layer — see Masking in include output.
Variables in include:#
GitLab expands $VAR / ${VAR} in include: project:, ref:, and file: before fetching the included file, so a pipeline can point at a template repo through variables:
include:
- project: $_GITLAB_TEMPLATES_REPO
ref: $_GITLAB_TEMPLATES_REF
file: '/includes/main.yml'
glci resolves these the same way. The variables available during include expansion are, lowest to highest precedence: predefined CI_* variables, the global variables: block, then your locally-supplied variables (--env, --env-file, pipeline preset env, .glci.env). The local layers stand in for the instance/group/project CI/CD variables GitLab would normally supply.
On many self-managed instances (Drupal contrib’s gitlab_templates, for example) these are instance-level variables. glci fetches instance variables only with an admin token (see Remote secrets), and remote variables are fetched in parallel with parsing — so they are not available at include-expansion time. Supply any variable an include path depends on locally:
# .glci.env (gitignored)
_GITLAB_TEMPLATES_REPO=project/gitlab_templates
_GITLAB_TEMPLATES_REF=1.0.x
Inspect the resolved include paths with glci jobs or glci variables; a literal $VAR reaching the API (an HTTP 404 fetching projects/$VAR/...) means the variable wasn’t supplied at any of the layers above. If a path in that message reads [MASKED], the value was treated as a secret — pass --unmask to see it.
Masking in include output#
Include paths are expanded before they are fetched, so any warning or error naming one quotes the value, not the $VAR that stood for it. A variable holding a token would otherwise reach your terminal in clear text.
glci masks the same values GitLab masks, and no others: a variable is hidden when it is marked, never because its value looks secret. That mirrors mask_variables_from in GitLab, which masks a variable if and only if it is flagged masked. Guessing from shape would be worse than useless: a local run that hid a value GitLab then prints in a job log would tell you the secret was protected when it never was.
Variables fetched from the API carry GitLab’s own masked flag and need nothing further. For the local layers (--env, --env-file, preset env:, .glci.env) there is no such flag, so name them yourself — --mask is the local equivalent of ticking Mask variable in the GitLab UI:
glci merged --mask PRIVATE_TOKEN # hide this variable's value
glci merged --unmask # print everything in clear text
--mask takes a variable name, not a value, so it applies whichever layer supplied it. Set it permanently for a preset in .glciconfig.toml:
[pipelines.deploy]
env = { PRIVATE_TOKEN = "glpat-xxxxxxxxxxxx" }
masked = ["PRIVATE_TOKEN"]
A project .glciconfig.toml may only add to a global preset’s masked list, never remove from it, so a cloned repository cannot unhide a variable you marked.
What GitLab will accept#
glci applies GitLab’s own masking requirements and refuses to hide anything GitLab would not, so what you see locally matches what a real pipeline prints. A value must be a single line, at least 8 characters, with no spaces, and use only letters, digits and _ : @ - + . ~ = /. A variable marked raw: is held to GitLab’s looser rule instead — any 8 or more characters with no whitespace. Only variables fetched from the API carry raw:, so a locally-supplied value is always held to the stricter rule; a local secret containing !, #, % or , cannot be masked, because GitLab would not mask it either without raw:.
Mark a name that none of those layers supplies, or one whose value GitLab would reject, and glci says so on stderr rather than quietly leaving it visible:
glci: warning: DEPLOY_PHRASE left visible: GitLab would not accept this value for masking, so hiding it
locally would misreport what a real pipeline does. …
That is the signal to change the value, not to work around it — GitLab will not mask it either.
Scope#
Redaction covers the percent-encoded and %q-quoted forms of a value as well as the raw one, so a secret containing / or " is still hidden once it reaches an API URL or a quoted path. It applies to glci lint, doctor, show, jobs, variables and merged, and to glci run — including the warnings and errors the daemon writes to ~/.glci/daemon.log, and the trigger job traces of child and cross-project pipelines.
lint and doctor carry the same variable flags as the other config-resolving commands, so a secret an include path interpolates can be marked there too. On doctor a mask warning arrives as a hint on the CI config row rather than as a free-standing line, because that command owns its report format.
When something is redacted, glci says so once on stderr and names --unmask, so a masked path is never mistaken for a failed one.
--mask covers include-resolution output only. It does not mask the glci variables table, glci run --show-variables, or a job’s trace — for a job trace, GitLab’s runner does the masking from the variable’s own masked flag.
Child and cross-project pipelines (trigger: with include: or project:) expand their includes against a wider set, because by the time a trigger job runs everything has been resolved. Lowest to highest precedence: predefined CI_* variables, the instance/group/project variables fetched from the API, the variables the trigger job forwards downstream (the parent’s global variables: merged with the trigger job’s own variables:), and finally your local layers (--env, --env-file, pipeline preset env, .glci.env).
The forwarded layer is what GitLab expands a downstream include: against, so a child whose include says ref: $TEMPLATES_REF picks up the value the trigger job set. See Variables in a child’s include paths.
Token forwarding#
By default, the host’s real GitLab token is forwarded into jobs as CI_JOB_TOKEN. Disable with --no-token, or restrict which jobs receive it in ~/.glci/config.toml:
# ~/.glci/config.toml
[token]
forward_host_token = true
stages = ["deploy"] # only deploy stage gets real token
jobs = ["release"] # specific job names
job_patterns = [".*-publish$"] # regex patterns
Jobs not matching the filter receive a synthetic mock-server token instead.
A project .glciconfig.toml may set [token] too, but it can only narrow what your global config allows — it decides whether your real token reaches scripts the repository wrote. forward_host_token = false in a project file is always honored; a project true is ignored when your global config explicitly set false; project filters are honored only when your global config sets none. Ignored keys are named in the glci: warning: .glciconfig.toml: ignoring ... line. See Token forwarding in a project config.
GitLab API token#
Required for include: project:, include: component:, fetching remote CI/CD variables, and auto-cloning trigger: project: targets.
Token resolution order (first match wins; --token flag always overrides):
GITLAB_TOKENorGITLAB_PRIVATE_TOKENenvironment variable[gitlab] tokenin.glciconfig.toml/~/.glci/config.tomlglab auth credential-helper– supports keyring, OAuth2 refresh, and PAT/CI tokens- Direct read of
~/.config/glab-cli/config.yml– legacy fallback
The simplest setup is glab auth login – the token is picked up automatically. The project path is auto-detected from your git remote; override with --project.
Exception: when the instance URL comes from a project .glciconfig.toml ([gitlab] url), every source except --token and [gitlab] token in that same project file is skipped – a file that travels with the repository must not be able to redirect your token to a host it picked, and that includes the token in ~/.glci/config.toml, which is your credential for your own instance. Otherwise requests go out unauthenticated and glci warns on stderr, naming the withheld source. See [gitlab] url in a project config.
Fallback project path#
When no git remote is configured (common for local-only test repos), CI_PROJECT_PATH defaults to local/<dirname> so variables like $CI_PROJECT_PATH are always available in job scripts.
Parallel job variables#
Jobs expanded by parallel: receive the same node variables GitLab sets, so a split test suite really is split locally instead of every shard running the whole thing.
| Variable | Value |
|---|---|
CI_NODE_INDEX | The 1-based index of the shard. Only set on jobs expanded by parallel: |
CI_NODE_TOTAL | The number of shards. 1 for a job without parallel: |
Both forms are covered: parallel: 4 numbers its shards 1 to 4, and parallel: matrix: numbers each permutation over the total permutation count.
test:
parallel: 4
script:
- pytest --splits $CI_NODE_TOTAL --group $CI_NODE_INDEX
On a shard both values are derived from the expansion and override anything the pipeline sets itself, so a stray CI_NODE_TOTAL in variables: cannot desynchronise a split. Without parallel: the 1 is only a default, and a CI_NODE_TOTAL the pipeline sets stands.
The variables a matrix: entry declares are in scope for the job’s rules:, so individual shards can be included or excluded by condition.
Difference from GitLab:
CI_NODE_INDEXandCI_NODE_TOTALare not. A rule testing either sees an empty value and glci will drop the job, where GitLab evaluates it against the shard’s real numbers. They are assigned from the expansion when the shard runs, which puts them below a job’s ownvariables:— a layer glci’s rule evaluation does not have yet. Tracked separately, along with the rest of the per-job predefined set (CI_JOB_NAME,CI_JOB_STAGE), which rules cannot see either.
For parallel: matrix: the permutations are enumerated the way GitLab enumerates them: in the order the matrix variables were declared, with the first-declared variable outermost. That order fixes both a shard’s name and its CI_NODE_INDEX, so a given index maps to the same combination locally as it does on GitLab.
deploystacks:
parallel:
matrix:
- STACK: [app, monitoring]
PROVIDER: [aws, gcp]
STACK is declared first, so the shards are deploystacks: [app, aws] (index 1), [app, gcp] (2), [monitoring, aws] (3), [monitoring, gcp] (4) — not the alphabetical PROVIDER-first pairing. Declaring PROVIDER first instead is a different pipeline: same four combinations, different names and indexes.
Because the shard name is an identifier, this is what glci run "deploystacks: [app, aws]", --skip/--manual filters, [jobs."<name>"] overrides, preset job lists and artifact lookups all match on. Copy a shard name out of the GitLab UI and it works unchanged.
Declaring the matrix through extends:, an include:, a YAML anchor, a merge key (<<:), or !reference to another job’s parallel: matrix: keeps the order it was written with. A <<: merging a list of aliases contributes their variables in reverse list order, which is where GitLab’s YAML parser puts them too. Two shapes have no declared order to read, and fall back to alphabetical variable order:
!referenceto a path that is not a job’sparallel: matrix:— a bare list under a hidden key, such asmatrix: !reference [.matrices, deploy]. Point the reference at[.template, parallel, matrix]instead and the order is kept.- a matrix set through
parallelin.glciconfig.toml, since TOML tables carry no declaration order.
Both are deterministic; only their shard names differ from the same matrix written inline.
glci lint reports the same diagnostics GitLab’s lint API returns for a parallel: block, so a matrix that would fail on push fails locally: parallel: outside 1-200, a matrix generating more than 200 jobs, an entry with no variables, and a value that is not a string or an integer (an unquoted 1.21 or true — quote them). A matrix over the job limit is left unexpanded rather than built, since a six-variable, ten-value matrix is a million jobs.
A variable declared with an empty list (VARIANT: []) crosses with nothing, so its entry generates no shards; a matrix that generates none at all — matrix: [], or every entry holding such a variable — removes the job from the pipeline, and any needs: on it, exactly as GitLab does.
Unsupported variables#
About 20 CI/CD variables cannot be set locally because they require a real GitLab server: CI_DEPLOY_*, CI_UPSTREAM_*, CI_EXTERNAL_PULL_REQUEST_*, KUBECONFIG, CHAT_*. GITLAB_USER_* variables are approximated from git config.
GLCI_PREFER_API#
Set GLCI_PREFER_API=1 to use the GitLab Lint API as the primary parser instead of the offline parser. Useful for edge cases where the offline parser differs from GitLab’s server-side behavior.
Context-derived variables#
glci sets CI variables based on the --context flag. See Context Simulation for the full variable table per context type.