Chapter 06Lesson 04~150 minutes

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.

DiagnosticsPrecedence failureMasking limitsQuotingProtection

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.
Diagnostic safety rule: preserve the original pipeline/job IDs and print only controlled synthetic values. Never respond to a variable incident with env, printenv, set -x, a complete context dump, or a real-secret transformation test.

1. Evidence-first diagnostic sequence

Variable diagnostics — source and authorization before shell speculation
            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=/' 
Never run this transformation against a real secret. The lesson is that masking cannot defend against code that intentionally transforms/exfiltrates values. Remove this synthetic demo job after the exercise.

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:

  1. YAML sets LAB_WINNER=yaml-job.
  2. Project settings define LAB_WINNER=project-setting.
  3. The branch is unprotected, so LAB_PROTECTED_FLAG is absent.
  4. LITERAL_FRAGMENT deliberately 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.

Diagnostic habit: describe evidence as facts—“project value won,” “protected value absent on unprotected ref,” “literal value preserved”—before assigning blame to a subsystem.

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.

Next lesson

Checkpoint Lab

Build the precedence matrix, prove protected/file-variable behavior, inject controlled failures, and produce a complete non-secret evidence packet.

Knowledge check

The log shows a project-setting value instead of the job YAML value. What is the first subsystem to inspect?

A protected variable is absent on a feature branch. Is unprotecting it the first repair?

Why must the masking transformation demo use a fake value?

Why does quoting a value in the shell not fix YAML octal parsing that already happened?

Why might an export from script be absent in after_script?

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.
Version and compatibility note

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.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0 Send only Ethereum/ERC-20 compatible assets to this address.