CI/CD Variables, Predefined Variables, File Variables, Masking, Protection, Expansion, and Precedence: Diagnostics, Failure Modes, Security, and Performance
Variable incidents often look mysterious because the effective value is assembled from several layers. This lesson diagnoses a higher-precedence value unexpectedly winning, a protected variable being absent, a masked synthetic value becoming visible after transformation, YAML typing and shell quoting changes, and expansion occurring in a different phase than expected.
Learning objectives
- Diagnose an unexpected value by tracing the winning variable source before editing YAML, runner configuration, or application code.
- Recognize that a missing protected variable on an unprotected ref is expected authorization behavior, not necessarily a runner defect.
- Demonstrate with a synthetic value why masking can fail after transformation and therefore cannot protect against malicious job code.
- Diagnose YAML scalar typing, quoting, and multi-phase expansion without printing unrelated environment values.
- Preserve the first failing pipeline/job evidence before rerunning so the causal value/source relationship remains reviewable.
env,
printenv, set -x, a complete context dump,
or a real-secret transformation test.
1. Evidence-first diagnostic sequence
flowchart TD
A[Preserve pipeline/job IDs + first logs] --> B[Confirm source/ref/SHA]
B --> C[Confirm compiled config + rules]
C --> D[Inventory only the target variable key sources]
D --> E[Check protection/type/visibility/expansion]
E --> F[Apply current precedence]
F --> G[Confirm runner/executor + shell]
G --> H[Inspect safe effective value or presence]
H --> I[Fix smallest causal layer]
I --> J[Rerun smallest safe scope]
The order prevents common misdiagnosis. A project variable that wins
precedence is not a shell bug. An absent protected value on an
unprotected ref is not a Runner bug. A literal
$BASE may be intentional because expansion is disabled.
2. Failure mode: the “wrong” value wins
Suppose the job prints LAB_WINNER=project-setting even
though the job YAML defines yaml-job. Preserve the
pipeline/job IDs, pipeline source/ref, and
CI_COMMIT_SHA, then inspect only the known
LAB_WINNER definitions. Current precedence says project
variables outrank YAML variables, so the result is correct.
| Observation | Do first | Do not do |
|---|---|---|
| Effective value differs from YAML | Inventory same-key project/group/pipeline/policy sources. | Edit runner config or rebuild image. |
| Different value only on manual run | Check manual-job/pipeline variables and source. | Assume GitLab cached old YAML. |
| Different value in subgroup project | Check nearest subgroup duplicate key. | Delete organization-wide variables blindly. |
| Value appears after upstream job | Check dotenv/dependency/needs dataflow. | Treat cache as the variable source. |
3. Failure mode: protected variable is missing
The job prints protected_present=no. Before changing
the variable, check whether the pipeline ref/tag is protected and
whether the current pipeline type is permitted to access protected
resources. On an ordinary unprotected feature branch, absence is the
expected secure state.
The wrong “fix” is to unprotect the variable. The correct fix depends on intent: run the deployment from the governed protected ref, change the architecture so untrusted code does not need the credential, or use a synthetic non-sensitive value for non-production tests.
4. Failure mode: masked synthetic value becomes visible after transformation
GitLab masking matches output patterns; it is not semantic secret
tracking. To teach this safely, use only a fake value such as
MaskDemo_2026_Only. A transformation like Base64
creates a different string that the masker may not recognize.
mask_limit_demo:
script:
# LAB_MASKED_DEMO MUST be a synthetic training value, never a real credential.
- printf 'exact=%s\n' "$LAB_MASKED_DEMO"
- printf '%s' "$LAB_MASKED_DEMO" | base64 | sed 's/^/transformed=/'
5. Failure mode: YAML typing changes a value before the shell sees it
YAML has scalar typing rules. GitLab documentation specifically
warns that unquoted numeric-looking values such as
012345 can be interpreted as octal by the YAML parser.
If the intended value is an identifier, quote it.
variables:
SAFE_ID: "012345"
# Avoid unquoted numeric-looking identifiers.
typing_probe:
script:
- printf 'SAFE_ID=%s\n' "$SAFE_ID"
Diagnose this at the configuration/YAML layer. Changing shell quoting after GitLab has already parsed the scalar cannot restore the original text.
6. Failure mode: expansion occurs in a different layer than expected
A literal $BASE_FRAGMENT can survive GitLab expansion
and then be expanded by the shell later, or remain literal depending
on how the value reaches the script. First identify whether the
field is expanded by GitLab, Runner, or the shell. Then inspect
variables:expand / UI expansion settings and the actual
shell quoting.
| Layer | Typical evidence | Common mistake |
|---|---|---|
| GitLab expansion | Merged/compiled configuration; variable metadata. | Assuming job-only values are available during pipeline creation. |
| Runner expansion | Runner-handled job fields and supported variables. | Assuming shell exports from the script can affect earlier Runner expansion. |
| Shell expansion | Job trace for controlled non-secret strings. |
Using unquoted variables or eval with untrusted
values.
|
7. before_script/script versus after_script variable state
GitLab Runner executes before_script and the main
script together in one shell context, while
after_script runs in a separate shell context. A
variable exported by user code during script therefore
is not automatically present in after_script.
shell_context_probe:
before_script:
- export LAB_RUNTIME_ONLY="created-in-main-shell"
script:
- printf 'script sees=%s\n' "$LAB_RUNTIME_ONLY"
after_script:
- printf 'after_script sees=%s\n' "${LAB_RUNTIME_ONLY:-<absent>}"
This is runtime shell state, not GitLab CI/CD variable precedence. Keep the concepts separate.
8. Service debugging can increase exposure risk
If a pipeline uses services, Chapter 03 introduced service logs and readiness. GitLab warns that enabling service debugging can reveal masked values in service-container logs. Do not enable broad debug modes on production pipelines just to investigate a variable.
Prefer a disposable reproduction with synthetic values and only the specific service/job involved. Preserve the original failure first, then reproduce safely.
9. Intentionally broken scenario: four layers, one causal diagnosis
Consider this sequence:
- YAML sets
LAB_WINNER=yaml-job. -
Project settings define
LAB_WINNER=project-setting. -
The branch is unprotected, so
LAB_PROTECTED_FLAGis absent. -
LITERAL_FRAGMENTdeliberately disables expansion.
A learner reports: “Runner ignored the YAML, lost my secret, and failed to expand a variable.” None of those statements identify the actual causes. The correct diagnosis is: project precedence, protected-ref authorization, and explicit literal expansion policy. Runner can be healthy throughout.
10. Repair the smallest causal layer
- If a project variable unintentionally shadows YAML, rename/remove only that duplicate after confirming ownership and scope.
- If protected data is correctly absent, do not make it unprotected; change the execution context or architecture.
- If expansion is unintended, set literal behavior explicitly; if expansion is intended, use a non-secret expandable value and document its dependency.
- If YAML typing changes data, quote the scalar in versioned configuration.
- After repair, rerun only the affected pipeline/job and compare with preserved first-failure evidence.
11. Performance and maintainability implications
Variables are usually not a compute bottleneck, but variable sprawl is an operational bottleneck. Thousands of vaguely named project/group values, duplicate keys, environment scopes, and hidden override channels make pipelines hard to review and incidents slow to diagnose.
Optimize for configuration comprehensibility: fewer authoritative sources, explicit ownership, stable naming, typed inputs for caller parameters, versioned files for structured non-secret configuration, and external secret systems for high-value credentials.
Knowledge check
The log shows a project-setting value instead of the job YAML value. What is the first subsystem to inspect?
Variable provenance/precedence, because project variables outrank YAML variables.
A protected variable is absent on a feature branch. Is unprotecting it the first repair?
No. First confirm whether that branch should have the capability at all; absence on an unprotected ref may be correct.
Why must the masking transformation demo use a fake value?
Because transformations can bypass log masking. Running it on a real secret would deliberately expose sensitive data.
Why does quoting a value in the shell not fix YAML octal parsing that already happened?
The data was changed by the YAML parser before the shell received it. Fix the versioned YAML scalar by quoting it there.
Why might an export from script be absent in after_script?
after_script runs in a separate shell context from before_script/main script, so runtime shell exports do not carry over.
Official references and version notes
- GitLab CI/CD variables — project/group/instance variables, visibility, masking, hiding, protection, file variables, expansion, precedence, pipeline-variable restrictions, and security guidance.
- Predefined CI/CD variables reference — current pre-pipeline, pipeline, and job-only availability phases and variable definitions.
- Where variables can be used — GitLab, GitLab Runner, and execution-shell expansion mechanisms and keyword-specific behavior.
-
CI/CD YAML syntax reference
—
variables, job variables,variables:expand,rules:variables,workflow:rules:variables, and inheritance semantics. - CI/CD inputs — typed/configuration inputs, interpolation, and the current recommendation to prefer inputs over ad-hoc pipeline variables for newer parameterized pipelines where applicable.
- Protected branches — current protected-ref behavior that underpins protected-variable availability.
Variable behavior in this chapter was rechecked against current primary GitLab documentation on 2026-09-11. The current documentation orders policy variables above manual-job and pipeline variables, then project/group/instance values, dotenv values, YAML variables, deployment variables, and most predefined variables. It also documents project/group variable visibility as Masked by default since GitLab 18.3, UI variable-reference expansion disabled by default since GitLab 18.6, and pipeline inputs as the preferred parameter mechanism over pipeline variables for newer designs where applicable. These are version-sensitive details: verify them again before publishing or automating production policy.
Keep the academy open
Support free, practical DevOps education.
Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0
Send only Ethereum/ERC-20 compatible assets to this
address.