Chapter 02Lesson 02~120 minutes

.gitlab-ci.yml Structure, Pipeline Compilation, Keywords, Defaults, and Configuration Validation: Guided Hands-On Workflow and Core Operations

Build Chapter 02 configuration progressively in a disposable project: begin with one visible job, introduce safe defaults, split configuration with a local include, inspect the fully expanded configuration, then deliberately create one structural failure and one valid-but-no-pipeline semantic failure. The goal is to prove which validation layer catches which class of problem.

Hands-onCI LintFull configurationLocal includesFailure injection

Learning objectives

  • Create a minimal configuration, add defaults, and split reusable configuration into a local include without introducing hidden external dependencies.
  • Inspect fully expanded configuration with the Pipeline Editor or current glab commands before running jobs.
  • Diagnose a structural validation error separately from a configuration that is valid but intentionally creates no pipeline.
  • Predict which jobs and inherited settings should exist before pipeline creation, then compare predictions with compiled evidence.
  • Complete the lab without real secrets, production runners, paid features, or uncontrolled external side effects.
Lab scope. Use a disposable GitLab project or disposable branch. The configuration prints synthetic metadata only, uses a local include, and performs no deployment, registry publication, runner registration, group mutation, or credential creation. CI Lint/Full configuration is sufficient if no runner is available.

1. Scenario and preflight

Create or select a disposable project such as GROUP/glci-ch02-config-lab. Record the current revision and existing CI state before writing files. If the project already has valuable CI configuration, use a dedicated disposable project instead of overwriting it.

LAB_BRANCH="ch02/config-compilation"
mkdir -p ch02-evidence .gitlab/ci

glab auth status
git status --short --branch
git rev-parse HEAD | tee ch02-evidence/head-before.txt

test ! -e .gitlab-ci.yml || { echo "Existing .gitlab-ci.yml detected; use a disposable project/branch."; exit 2; }
Preflight guard: the last line intentionally stops if a CI file already exists. Do not overwrite an unrelated project pipeline for a course exercise.

2. Start from an empty file and one visible job

verify_config:
  script:
    - printf 'source=%s\n' "$CI_PIPELINE_SOURCE"
    - printf 'sha=%s\n' "$CI_COMMIT_SHA"

This is intentionally small. It defines one visible job and one script. Do not add images, services, variables, caches, artifacts, or deployment state yet. First validate the smallest GitLab CI document and preserve the result.

glab ci lint .gitlab-ci.yml | tee ch02-evidence/01-minimal-lint.txt

3. Add safe defaults, then inspect what changed

default:
  retry: 1
  interruptible: true
  before_script:
    - printf 'preflight=chapter02\n'

verify_config:
  script:
    - printf 'source=%s\n' "$CI_PIPELINE_SOURCE"
    - printf 'sha=%s\n' "$CI_COMMIT_SHA"

The important step is not typing default; it is proving the compiled job now contains those inherited settings. Use the Pipeline Editor Full configuration tab or the current CLI compiler.

glab ci lint .gitlab-ci.yml --include-jobs \
  | tee ch02-evidence/02-defaults-lint.txt

glab ci config compile \
  | tee ch02-evidence/02-defaults-compiled.yml

4. Split one local include without changing the trust boundary

Create a repository-local file. A local include is resolved from the same repository/branch context as the file that includes it, so this exercise teaches compilation without introducing a second project or public URL.

# .gitlab/ci/base.yml
default:
  retry: 1
  interruptible: true
  before_script:
    - printf 'preflight=chapter02\n'

# .gitlab-ci.yml
include:
  - local: /.gitlab/ci/base.yml

stages:
  - verify

verify_config:
  stage: verify
  script:
    - printf 'source=%s\n' "$CI_PIPELINE_SOURCE"
    - printf 'sha=%s\n' "$CI_COMMIT_SHA"

standalone_check:
  stage: verify
  inherit:
    default: false
  script:
    - printf 'standalone=true\n' 

Predict first: verify_config should contain the inherited defaults; standalone_check should not. Then inspect the expanded result and the included-file tree in Pipeline Editor.

5. Static lint, compiled configuration, and pipeline simulation answer different questions

# Static validation and jobs reported by current glab:
glab ci lint .gitlab-ci.yml --include-jobs \
  | tee ch02-evidence/03-lint-with-jobs.txt

# Fully merged/compiled configuration:
glab ci config compile \
  | tee ch02-evidence/03-compiled.yml

# Simulate pipeline creation for a branch/ref when supported by your current glab:
glab ci lint .gitlab-ci.yml --dry-run --ref "$LAB_BRANCH" --include-jobs \
  | tee ch02-evidence/03-simulation.txt

CI Lint simulation is more than a syntax check: it asks GitLab to simulate pipeline creation and can expose problems involving rules or graph semantics. The UI simulation documented by GitLab uses a push event on the default branch; CLI --ref lets you select a branch/tag context for its dry run.

6. Failure injection A: create a structural/type error

Preserve the working file first, then make stages the wrong YAML type. GitLab expects a sequence of stage names, not one scalar.

cp .gitlab-ci.yml ch02-evidence/04-good-before-structural.yml

# Deliberately broken fragment:
stages: verify

verify_config:
  stage: verify
  script:
    - echo "this configuration should not pass GitLab CI validation"

Run CI Lint and save the failure. Do not “fix while reading” and lose the original diagnostic text. The correct conclusion is configuration validation failed before runner assignment.

glab ci lint .gitlab-ci.yml \
  > ch02-evidence/04-structural-error.txt 2>&1 || true
cat ch02-evidence/04-structural-error.txt
cp ch02-evidence/04-good-before-structural.yml .gitlab-ci.yml

7. Failure injection B: valid configuration that intentionally creates no pipeline

Now create a different class of problem. The YAML and GitLab keywords are valid, but pipeline creation is suppressed.

workflow:
  rules:
    - when: never

verify_config:
  script:
    - echo "this job is valid but no pipeline should be created"

A static configuration check can be valid because the configuration is structurally legitimate. Pipeline simulation or a real source event is where the no-pipeline outcome becomes visible. This is the exact reason validation state and pipeline-creation state must remain separate.

Do not misdiagnose this as a runner outage. No job reaches the queue because no pipeline/job graph is materialized.

8. Compare compiled evidence with runtime evidence

Restore the working include-based configuration. If an eligible disposable runner exists, commit it on the lab branch and create a pipeline. If no runner exists, stop at lint/simulation and record that limitation; do not register a privileged runner merely to make the lab green.

git switch -c "$LAB_BRANCH"
git add -- .gitlab-ci.yml .gitlab/ci/base.yml
git diff --cached --check
git diff --cached
git commit -m "ch02: add compiled configuration lab"
LAB_SHA="$(git rev-parse HEAD)"
printf '%s\n' "$LAB_SHA" | tee ch02-evidence/lab-sha.txt
# Optional only in an authorized disposable project:
git push -u origin "$LAB_BRANCH"
Source evidence

Exact commit SHA and root/include file paths.

Compilation evidence

Lint result and expanded/compiled YAML.

Pipeline evidence

Pipeline source/ref/SHA and final job names, if created.

Runner evidence

Runner/executor metadata only if jobs actually start.

9. Challenge: pick the owning layer

Classify each symptom before changing anything:

Symptom First owning layer Why
CI Lint reports stages has the wrong type. Configuration validation No pipeline/job exists yet.
Static lint is valid; simulation yields no pipeline. Pipeline creation / workflow rules Runner capacity is irrelevant.
Pipeline and job exist; job stays pending. Runner eligibility/capacity Configuration already materialized the job.
Job starts; shell command exits 1. Runtime script/tool layer Compilation and runner assignment already succeeded.

10. Cleanup and verification

Preserve evidence long enough to compare the failures, then remove only the disposable branch/project state you created. Confirm exact branch identity before deletion.

git status --short
git rev-parse HEAD
git ls-remote --heads origin "$LAB_BRANCH"
# Optional cleanup after verification in a disposable project:
# git push origin --delete "$LAB_BRANCH"
# git switch main
# git branch -D "$LAB_BRANCH"
Next lesson

Configuration, Design Choices, and Tradeoffs

Use the same compiled-configuration mental model to decide when defaults, inheritance, includes, and each validation depth improve maintainability—and when they hide too much.

Knowledge check

Why deliberately save the structural-error output before restoring the file?

A file passes static CI Lint but workflow:rules contains only when: never. What should simulation show?

Why use a local include in this chapter instead of a remote URL?

A job lacks the expected before_script. What evidence should you inspect before runner logs?

If no runner is available, is the lab incomplete?

Official references and version notes

Version and compatibility note

Version-sensitive statements in this chapter were rechecked against current primary GitLab documentation on 2026-09-11. Core CI Lint, Pipeline Editor validation, default, inherit, and local includes are documented across GitLab Free, Premium, and Ultimate on GitLab.com, GitLab Self-Managed, and GitLab Dedicated. The labs deliberately avoid depending on paid policy features, production credentials, external cloud accounts, or privileged runner administration.

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.