GitLab CI Reference
Pipeline patterns with rules, artifacts and caching. Each keyword below was checked against the GitLab documentation. The YAML samples were checked for syntax only and were not run on a GitLab instance.
Anatomy of .gitlab-ci.yml
A pipeline is created from .gitlab-ci.yml. It contains jobs. Each job belongs to a stage. Jobs in the same stage run in parallel, and the next stage starts after the jobs of the previous stage succeed. If you do not define stages, the defaults are .pre, build, test, deploy and .post, and a job with no stage goes to test.
stages:
- build
- test
- deploy
default:
image: alpine:3.20
before_script:
- echo "Running on $CI_COMMIT_REF_NAME"
variables:
APP_ENV: ci
build:
stage: build
script:
- mkdir -p dist
- echo "$APP_ENV" > dist/app.txt
artifacts:
paths:
- dist/
expire_in: 1 week
test:
stage: test
script:
- test -f dist/app.txtWhat each keyword does:
script: the shell commands the runner executes.image: the container image the job runs in.before_script: runs beforescript, after artifacts are restored, and is concatenated withscriptinto one shell.after_scriptruns in a separate new shell and does not change the job exit code.default: each default keyword is copied to every job that does not already define it. A job that sets its ownbefore_scriptdoes not also get the default one.variables: CI/CD variables for all jobs (top level) or for one job. Job-level values override the top-level ones.- Names starting with a dot (
.setup) are hidden jobs. They are never run and serve as templates.
For a first hands-on pipeline, follow the first GitLab CI pipeline tutorial on this site.
Rules and workflow:rules
rules decide whether a job is created. Rules are evaluated in order until the first match. If no rule matches, the job is not added to the pipeline. rules is the documented way to control jobs: the GitLab reference lists only and except as deprecated keywords that are no longer recommended.
if: a CI/CD variable expression. Common variables are$CI_PIPELINE_SOURCE,$CI_COMMIT_BRANCH,$CI_COMMIT_TAGand$CI_DEFAULT_BRANCH.changes: match when listed files changed (paths, optionallycompare_to).when:on_success(default),manual,delayed,neverand others.allow_failure: whether the pipeline continues if the job fails (defaultfalse, but see manual jobs below).
test:
stage: test
script:
- make test
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
docs:
stage: build
script:
- make docs
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
changes:
paths:
- 'docs/**/*'
release:
stage: deploy
script:
- make release
rules:
- if: $CI_COMMIT_TAGThese three jobs cover the standard patterns: merge request pipelines, the default branch, and tags. $CI_COMMIT_BRANCH is not available in merge request pipelines or tag pipelines, so the tag job tests $CI_COMMIT_TAG.
changes is not always a filter. On a new branch or tag, rules:changes is always true because there is no previous commit to compare against. It is also always true for scheduled pipelines and for pipelines not triggered by a push (api, web, trigger). Otherwise the files compared depend on the pipeline type: see rules:changes and rules:changes:compare_to in the YAML reference.
workflow:rules: which pipelines exist
workflow:rules controls whether a pipeline is created at all. Without it, one push to a branch with an open merge request can create both a branch pipeline and a merge request pipeline. This is the pattern from the documentation, plus a tag rule:
workflow:
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS
when: never
- if: $CI_COMMIT_BRANCH
- if: $CI_COMMIT_TAGRead it top to bottom. Merge request events run. A branch push with an open merge request is skipped (when: never), so no duplicate. Other branch pushes run, and tags run. $CI_COMMIT_BRANCH is not available in tag pipelines, so the last rule is what lets them through. If no workflow rule matches, the pipeline does not run, and a job configured for tags never runs if the workflow prevents tag pipelines. Check that every pipeline type you need matches a rule.
Artifacts vs cache
Both define their paths relative to the project directory and cannot link to files outside it. Their purpose differs.
| Artifacts | Cache | |
|---|---|---|
| Purpose | Pass job results (builds, reports) between jobs and stages, or download them from GitLab. | Reuse downloaded dependencies between runs. |
| Stored | In GitLab. | On the runner machine, or S3 if distributed cache is enabled. |
| Guaranteed | Stored in GitLab and downloadable until they expire. | No. The documentation says caching is an optimization that is not guaranteed to always work. |
| Default fetch | Jobs fetch artifacts from jobs in previous stages. | Restored by the cache key. |
.deps-cache:
cache:
key:
files:
- package-lock.json
paths:
- .deps/
build:
extends: .deps-cache
stage: build
script:
- make deps build
artifacts:
paths:
- dist/
expire_in: 1 week
test:
extends: .deps-cache
stage: test
cache:
policy: pull
script:
- make test
artifacts:
when: always
paths:
- reports/
expire_in: 1 weekartifacts:pathslists what to keep.expire_insets how long. If you omit it, the instance-wide default is used. Thenevervalue keeps them without expiry.artifacts:whenison_successby default. Usealwaysto upload test reports from failed jobs too.cache:key:filesbuilds the key from file contents (at most two files). If none of the files exist, the key isdefault. Changing the lock file gives a new cache, which is the point.cache:policyispull-pushby default.pullonly downloads,pushonly uploads. The caching documentation describes a pattern with one job that fills the cache and pull-only jobs that read it.
Never use cache as a hand-off. If a later job needs a file that an earlier job produced, use artifacts. Jobs must be able to regenerate anything they take from the cache.
needs: DAG pipelines
By default, stages set the order. needs builds a directed acyclic graph (DAG): a job starts as soon as the jobs it lists have finished, even if other jobs in earlier stages are still running. needs: [] starts a job immediately. By default a job fetches artifacts from jobs in previous stages; needs:artifacts and dependencies narrow that to the jobs you name.
stages:
- build
- test
- deploy
build:
stage: build
script:
- make build
lint:
stage: test
needs: []
script:
- make lint
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
unit:
stage: test
needs:
- build
script:
- make test
deploy:
stage: deploy
needs:
- unit
- job: lint
optional: true
script:
- make deployIf a needed job is missing from the pipeline because of rules, pipeline creation fails with an error such as ‘unit_tests’ job needs ‘compile’ job, but ‘compile’ does not exist in the pipeline. In the example, lint exists only in merge request pipelines, so deploy marks it optional: true. The other choice is to give both jobs matching rules.
Reuse: extends, !reference, include
Three tools remove copy and paste. extends merges hashes from a template job, and arrays are replaced, not merged. !reference pulls one part of another job, such as its script. include loads other YAML files (for example local, project, remote and template).
include:
- local: 'ci/build.yml'
- project: 'my-group/ci-templates'
ref: main
file: 'templates/deploy.yml'
.setup:
script:
- echo "shared setup"
.base:
image: alpine:3.20
variables:
LEVEL: base
job-a:
extends: .base
script:
- !reference [.setup, script]
- echo "level is $LEVEL"- When keys overlap, the main
.gitlab-ci.ymltakes precedence over included files. - Plain YAML anchors work, but not across files pulled in with
include.!referencecan point to configuration in included files. - The documentation advises avoiding more than three levels of
extendsbecause of complexity.
Environments, manual gates, variables
The environment keyword names the target a job deploys to. If the environment does not exist when the pipeline runs, it is created. A manual gate is when: manual in a rule.
deploy-production:
stage: deploy
script:
- ./deploy.sh production
environment:
name: production
url: https://example.com
rules:
- if: $CI_COMMIT_TAG
when: manual
allow_failure: falseA manual job inside rules defaults to allow_failure: false, which is a blocking manual job. The pipeline stops at that stage with status blocked until someone runs it. Outside rules, the default is true, so the pipeline does not wait. Blocked pipelines cannot be merged when Pipelines must succeed is enabled. Combine a blocking manual job with a protected environment and only users in the Allowed to deploy list (or GitLab administrators) can run it.
Variables and secrets
- Protected variables are available only to pipelines on protected branches or protected tags. Use them for production credentials.
- Masked variables show as
[MASKED]in job logs. The value must be a single line, at least 8 characters, with no spaces. GitLab states that masking is not a guaranteed way to stop a malicious user from reading a value. - Store tokens and passwords in the project settings, not in
.gitlab-ci.yml. Values set in the UI take precedence over variables in the YAML file.
Troubleshooting
| Symptom | Likely cause and fix |
|---|---|
| Two pipelines per push | Branch and merge request pipelines both created. Add workflow:rules with $CI_OPEN_MERGE_REQUESTS. |
| No pipeline at all | No workflow:rules entry matched (for example tag pipelines without a tag rule). Add a rule that matches that pipeline type. |
| Job is missing | No job rule matched, so the job was excluded. Check if conditions against the pipeline type. |
changes job runs every time | Always true on new branches, tags, schedules and non-push pipelines. Add an if and use merge request pipelines. |
| Error: job needs a job that does not exist | The needed job was removed by rules. Use optional: true or align the rules. |
| Cache empty or missing | Cache is best effort and depends on runners. Make jobs regenerate it, and pass results with artifacts. |
| No artifacts from a failed job | Default is on_success. Set artifacts:when: always or on_failure. |
| Pipeline shows blocked | A manual job in rules with allow_failure: false is waiting. Run it or set allow_failure: true. |
| Variable is empty on a branch | It may be protected, and the branch is not. Check the variable flags and the branch protection. |
Validate before you push. Check YAML syntax with a linter first. Then use the CI lint tool in Build > Pipeline editor > Validate. It checks syntax including include files, and has an option to simulate pipeline creation. The simulation runs as a push event on the default branch, so it does not reproduce merge request or tag pipelines.
Official documentation
Keep going
benmabrouk.fr: free DevOps and SRE learning resources, written from production experience.