9. Reusable and Modular Configuration
A single .gitlab-ci.yml works well for one project. An organization with dozens or hundreds of projects quickly ends up copying the same jobs everywhere, and every fix then has to be repeated in every copy. This chapter covers the tools GitLab provides to write CI configuration once and reuse it: include to assemble a pipeline from several files, extends and !reference to share configuration between jobs, CI/CD components with typed inputs and versioned releases published in the CI/CD Catalog, and the built-in templates that ship with GitLab. Knowing exactly how these pieces are merged is what separates a maintainable CI platform from a pile of copy-pasted YAML.
<a id="91-including-external-configuration"></a>
9.1 Including External Configuration
include pulls YAML from other files into the pipeline configuration before anything else is evaluated. This subchapter covers the four classic sources (a file in the same repository, a file in another project, a URL and a template shipped with GitLab), how to include files conditionally, and exactly how included content is merged with your own.
include:local
Why it matters. A 600-line .gitlab-ci.yml is hard to read and hard to review. Every merge request that touches CI shows a diff in the same large file, and nobody can tell who owns which part. Splitting the configuration by concern (build, test, deploy) into several files in the same repository gives smaller files, separate code owners per file, and the same versioning as the code itself: the CI configuration on a branch always matches the code on that branch.
What it is. include:local includes a file from the same project, at the same commit as the pipeline. Think of it as #include in C: before GitLab evaluates anything, the included files are combined with .gitlab-ci.yml into one configuration. The path is relative to the repository root (a leading / is optional), and the file must end in .yml or .yaml.
include:local is one of five include types. The others are covered in the following topics and in section 9.3:
| Type | Source | Version used | Typical use |
|---|---|---|---|
local | The same repository | The same commit as the pipeline | Splitting a large file |
project | Another project on the same instance | ref (default branch if omitted) | Organization-wide shared configuration |
remote | A public HTTP(S) URL | Whatever the URL returns at pipeline creation | Configuration hosted outside GitLab |
template | Templates bundled with GitLab | Your GitLab version | Security scanners, workflow presets |
component | A CI/CD component | @version in the reference | Versioned, parameterized building blocks (9.3) |
A typical split:
.
├── .gitlab-ci.yml # stages, workflow and includes only
└── ci/
├── build.yml
├── test.yml
└── deploy/
├── staging.yml
└── production.yml
# .gitlab-ci.yml
stages: [build, test, deploy]
include:
- local: ci/build.yml # path from the repository root
- local: /ci/test.yml # a leading slash is optional
- local: ci/deploy/*.yml # wildcard: every .yml file directly in ci/deploy/
# ci/test.yml
unit-tests:
stage: test
image: python:3.12-slim
script:
- pip install -r requirements.txt
- pytest tests/unit
Short forms. When an entry is a plain string, GitLab infers the type: a path is a local file, and a value starting with http:// or https:// is a remote file.
include: ci/build.yml # one local file
include: # several local files
- ci/build.yml
- ci/test.yml
Wildcards. * matches files in one directory. ** matches files at any depth. Note GitLab's syntax for recursive matching: ci/**.yml, not the ci/**/*.yml form used by many other tools.
| Pattern | Matches |
|---|---|
ci/*.yml | ci/build.yml, ci/test.yml, but not ci/deploy/staging.yml |
ci/**.yml | Every .yml file under ci/, at any depth |
Rules and limitations.
- The file must be committed on the same branch. A file that exists only on
mainis invisible to a feature branch pipeline until the branch is rebased or merged. - Files inside Git submodules cannot be included.
- YAML anchors defined in one file cannot be used in another (section 2.1). Share configuration across files with
extendsor!reference(9.2). - In a file included from another project,
include:localrefers to that other project (next topic).
Pair the split with a CODEOWNERS entry such as /ci/deploy/ @acme/platform-team so that changes to deployment configuration need approval from the people who own it.
Key takeaways
include:localcombines files from the same repository and commit into one configuration before evaluation.- Paths start at the repository root and must end in
.ymlor.yaml.*matches one directory,**matches recursively. - Local includes are versioned with the code: a branch always sees its own CI files.
- Anchors do not cross file boundaries. Use
extendsor!referenceinstead.
Practice
- (Beginner) Split a single-file pipeline with build, test and deploy jobs into
ci/build.yml,ci/test.ymlandci/deploy.yml. Confirm in the pipeline editor's Full configuration tab that the result is unchanged.- (Intermediate) Create
ci/services/api.ymlandci/services/web.yml, and include both with one wildcard entry. Then addci/services/legacy/old.ymland adjust the pattern so that it is included too. Add aCODEOWNERSrule forci/.- Interview question: "A developer adds a new included file on a feature branch, but the pipeline on
mainfails after the merge with an error about a missing job template. What could have happened?" Hint: which commit each pipeline reads its local includes from, and what happens to files that were only on the branch or only onmain.
include:project
Why it matters. A platform team maintains the organization's Docker build, deployment and notification jobs in one project, and two hundred application projects include them. A fix made once reaches everyone. That is also the danger: a broken change to the shared file breaks two hundred pipelines at the same time. include:project gives you the sharing, and its ref gives you control over when changes arrive.
What it is. include:project includes one or more files from another project on the same GitLab instance.
| Keyword | Meaning |
|---|---|
project | Full path of the project, such as platform/ci-templates |
file | One path or a list of paths, relative to that project's root |
ref | Branch, tag or commit SHA. If omitted, the HEAD of the project's default branch |
include:
- project: platform/ci-templates
ref: v3.2.0 # tag: a reviewed, released version
file:
- /templates/docker-build.yml
- /templates/helm-deploy.yml
- project: platform/ci-templates
ref: 4f2a9c1e8b7d6a5f3e2d1c0b9a8f7e6d5c4b3a2f # full SHA: cannot change
file: /templates/security.yml
- project: platform/ci-templates # no ref: default branch HEAD,
file: /templates/notify.yml # changes whenever someone merges there
Choosing a ref.
| Ref | Stability | Updates | Use when |
|---|---|---|---|
| None (default branch) | Lowest: every merge to the template project reaches you | Automatic | Internal tooling you fully trust, or during development |
Release branch (stable) | Controlled channel | Automatic, when the channel moves | A platform team runs explicit release trains |
Tag (v3.2.0) | High, if tags are protected | Manual bump | Most production projects |
| Commit SHA | Immutable | Manual bump (a dependency bot can automate it) | High-security projects and compliance requirements |
Variables in includes. project, ref and file can contain variables, but only those that exist before the configuration is read: project, group and instance variables, predefined variables such as $CI_PROJECT_PATH and $CI_COMMIT_REF_NAME, and variables passed by triggers, schedules or manual runs. Variables from the YAML variables: section or from jobs are not available. A group variable makes a useful rollout switch:
include:
- project: platform/ci-templates
ref: $CI_TEMPLATES_REF # group variable: "v3.2.0" for most groups, "v4.0.0-rc1" for pilot groups
file: /templates/docker-build.yml
Permissions. The user who starts the pipeline must be able to read the repository of the included project. If not, the pipeline is not created and the error reads similar to: Project `platform/ci-templates` not found or access denied!. For organization-wide templates, make the project internal (or public), or give the relevant groups at least the Reporter role.
Nested includes. A file included from another project can include further files. A nested include:local refers to the project and ref of the file that contains it, not to your project:
your-app (.gitlab-ci.yml)
└─ include:project platform/ci-templates @ v3.2.0 file: /templates/docker-build.yml
└─ include:local /templates/common/registry-login.yml
-> read from platform/ci-templates @ v3.2.0, not from your-app
Security. Anyone who can push to the template project can run code in every pipeline that includes it, with access to those pipelines' secrets. Protect the default branch and release tags of template projects, require code owner approval, and prefer tags or SHAs for projects that handle production credentials.
Key takeaways
include:projectshares files across projects.fileaccepts a list, andrefselects the version.- Omitting
reffollows the default branch, so every merge there reaches you immediately. Pin a tag or SHA for stability. - Only variables that exist before the configuration is read (project, group, instance, predefined, trigger and schedule variables) can be used in
project,refandfile. - The pipeline's user needs read access to the included project.
- A nested
include:localresolves inside the project that contains it.
Practice
- (Beginner) Create a
ci-templatesproject with atemplates/hello.ymlfile containing one job. Include it from another project, first withoutrefand then with a tag. Change the template on its default branch and observe which pipelines pick up the change.- (Intermediate) In the template project, make
templates/hello.ymlincludetemplates/common.ymlthroughinclude:local. Verify in the consuming project's Full configuration tab where the nested file was read from. Then move therefinto a group variable and switch versions without editing the consuming project.- Interview question: "Your company has 300 projects that include shared CI files from one project without a
ref. A bad merge broke all of them on a Monday morning. What would you change?" Hint: versioned refs, protected tags, a test pipeline in the template project, and a staged rollout through variables.
include:remote
Why it matters. Some configuration lives outside your GitLab instance: on another GitLab instance, on GitHub, or on an internal web server. include:remote fetches it by URL. It is the most convenient include type and also the one with the most risks, because neither the content nor the availability of the URL is under your control.
What it is. include:remote downloads a YAML file with an HTTP GET request when the pipeline is created. The URL must be reachable from the GitLab server and public: no authentication headers or tokens can be sent. Since GitLab 17.9, the optional integrity keyword pins the expected content with a SHA-256 hash in Subresource Integrity format, and the pipeline fails if the downloaded file does not match.
include:
- remote: https://raw.githubusercontent.com/acme/ci-snippets/1.4.0/lint.yml # version in the URL path
integrity: sha256-L3/GAoKaw0Arw6hDCKeKQlV1QPEgHYxGBHsH4zG1IY8= # base64 SHA-256 of the file
Compute the integrity value from the exact file:
# Download the file and print its SHA-256 digest in base64 (Subresource Integrity format)
curl --silent --fail https://raw.githubusercontent.com/acme/ci-snippets/1.4.0/lint.yml \
| openssl dgst -sha256 -binary \
| openssl base64 -A
Risks and mitigations.
| Risk | What happens | Mitigation |
|---|---|---|
| Availability | The server is down or slow, so pipeline creation fails | Mirror the file into a GitLab project and use include:project |
| Mutability | The file changes without notice, so pipelines change behavior | Use a versioned URL (tag in the path) plus integrity |
| Trust | The fetched YAML runs scripts with your project's secrets | Include only from sources you control or have reviewed |
| No authentication | Private files cannot be fetched | Use include:project for private configuration |
Each pipeline creation fetches the file again, and all includes of a pipeline must be resolved within a total time limit (30 seconds by default), so a slow remote host delays or breaks pipeline creation.
Key takeaways
include:remotefetches a public YAML file by URL every time a pipeline is created. Authentication is not supported.- The content can change at any time. Pin the version in the URL and add
integrityto detect changes. - A remote file is code that runs with your secrets. Review it like a dependency.
- For anything private or critical, mirror it into a GitLab project and use
include:project.
Practice
- (Beginner) Host a small YAML file with one job in a public GitLab snippet or repository, include it with
include:remoteusing its raw URL, and run the pipeline.- (Intermediate) Add an
integrityhash to the include. Change one character in the remote file and record the error. Then compute the new hash with theopensslcommand above and fix the include.- Interview question: "Why might a security team forbid
include:remotein production projects, and what would you offer instead?" Hint: consider who controls the content, what runs with which secrets, availability, and mirroring intoinclude:projectwith pinned refs.
include:template
Why it matters. GitLab ships ready-made CI configuration inside the product: security scanners, workflow presets, Auto DevOps jobs and starter pipelines for many languages. include:template adds one of them to your pipeline with a single line, without copying anything.
What it is. The value is the template's path inside GitLab's template directory (lib/gitlab/ci/templates/ in the GitLab source code). The template content comes from your GitLab instance, so its version is the version of GitLab you run.
include:
- template: Jobs/SAST.gitlab-ci.yml # static application security testing
- template: Jobs/Secret-Detection.gitlab-ci.yml # leaked credentials in the repository
- template: Workflows/MergeRequest-Pipelines.gitlab-ci.yml # workflow:rules for MR-based pipelines
Versioning. Because templates follow the instance version:
- Upgrading GitLab can change the jobs you include. Read the release notes for template changes before upgrading.
- Some templates have a
.latestvariant (for exampleJobs/SAST.latest.gitlab-ci.yml). It contains changes planned for the next major release, so you can test them early. Switch back to the stable name for production use. - To freeze a template, copy it into your repository (losing updates), or move to the equivalent CI/CD component, which supports pinned versions (9.3).
Reading a template. To see the exact content your instance uses, read it in the GitLab source code for your version, or check the Full configuration tab after including it. The templates API lists the starter pipeline templates (the root-level ones described in 9.4):
# List starter pipeline templates available on this instance
curl --silent --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
"https://gitlab.example.com/api/v4/templates/gitlab_ci_ymls?per_page=100" | jq -r '.[].key'
# Print the content of one of them
curl --silent --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
"https://gitlab.example.com/api/v4/templates/gitlab_ci_ymls/Python" | jq -r '.content'
Templates and components. GitLab is moving reusable CI functionality from templates to CI/CD components, which have typed inputs and explicit versions. Many templates, including the main security scanners, already have component equivalents. Section 9.4 covers how to use and customize templates.
Key takeaways
include:templateadds a GitLab-bundled template by its path, such asJobs/SAST.gitlab-ci.yml.- The template version is the GitLab version: upgrades can change your pipeline.
.latestvariants preview upcoming breaking changes. Do not leave them in production.- Inspect the merged result in the Full configuration tab before relying on a template.
Practice
- (Beginner) Include
Jobs/Secret-Detection.gitlab-ci.ymlin a project, run a pipeline, and find the job it adds and the report artifact it uploads.- (Intermediate) Include
Workflows/MergeRequest-Pipelines.gitlab-ci.ymland use the Full configuration tab to read theworkflow:rulesit adds. Push to a feature branch with and without an open merge request and explain which pipelines are created.- Interview question: "Your SAST jobs suddenly behave differently after a GitLab upgrade, although nobody changed
.gitlab-ci.yml. How is that possible and how would you prevent surprises?" Hint: whereinclude:templatecontent comes from, release notes,.latestvariants, and versioned components.
Conditional Includes with rules
Why it matters. Not every file is needed in every pipeline. Node.js jobs are useless in a repository without package.json, deployment configuration only matters on the default branch, and a team may want to opt in to an expensive scanner. include:rules decides whether a file is included at all, which keeps the configuration and the pipeline view smaller than disabling each job individually.
What it is. include entries accept rules with if, exists and changes, and when: never or when: always. If a rule matches (and is not when: never), the file is included. If no rule matches, it is skipped. The rules are evaluated once, while the configuration is built, before workflow:rules and before any job rules.
include:
- local: ci/node.yml
rules:
- exists: # the file exists in the repository at this commit
- package.json
- local: ci/python.yml
rules:
- exists:
- pyproject.toml
- requirements*.txt # glob patterns are allowed
- local: ci/deploy.yml
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
- local: ci/docs.yml
rules:
- changes: # files changed by this push or merge request
- docs/**/*
- project: platform/ci-templates
ref: v3.2.0
file: /templates/dast.yml
rules:
- if: $ENABLE_DAST == "true" # project or group variable: an opt-in switch
Which variables are available. Only variables that exist before the configuration is read: predefined variables, project, group and instance variables, and variables passed by triggers, schedules, the API or the Run pipeline form. Variables defined in the YAML variables: section are not available, and a condition that uses them never matches:
# Broken: ENABLE_E2E is defined in the YAML itself, so the include is always skipped
variables:
ENABLE_E2E: "true"
include:
- local: ci/e2e.yml
rules:
- if: $ENABLE_E2E == "true"
Two fixes: keep the include:rules and define ENABLE_E2E as a project or group variable (Settings > CI/CD > Variables) instead of in the YAML, or include the file unconditionally and move the decision into job rules, which do see YAML variables:
# Fixed (job-level variant): the file is always included, each job decides for itself
variables:
ENABLE_E2E: "true"
include:
- local: ci/e2e.yml
# ci/e2e.yml
e2e-tests:
rules:
- if: $ENABLE_E2E == "true" # job rules are evaluated after the YAML variables are known
script: [npm run test:e2e]
include:rules compared with job rules.
include:rules | Job rules | |
|---|---|---|
| Decides | Whether a whole file is part of the configuration | Whether one job is added to the pipeline |
| Evaluated | Once, while the configuration is built | For each job, after the configuration is merged |
YAML variables: usable | No | Yes |
| Supported clauses | if, exists, changes, when: never/always | All rules keywords, including variables, needs, allow_failure |
Dependency pitfall. If a job in .gitlab-ci.yml uses extends: .deploy-template and .deploy-template lives in a conditionally included file, every pipeline that skips the file fails with an error about an unknown extends key. Keep templates and the jobs that use them in the same conditional file, or keep templates in an unconditional file.
Key takeaways
include:rulessupportsif,existsandchanges, and is evaluated once while the configuration is built.- Use it to include whole files by repository content (
exists), branch (if) or opt-in variables. - YAML-defined
variables:are not available ininclude:rules. Use project or group variables, or job rules. - Jobs must not depend on templates from a file that might be skipped.
Practice
- (Beginner) Include
ci/node.ymlonly whenpackage.jsonexists. Deletepackage.jsonon a branch and confirm that the Node jobs disappear.- (Intermediate) Build a monorepo configuration where
ci/frontend.ymlis included only if files underfrontend/changed, andci/release.ymlonly for tag pipelines. Then reproduce the "unknownextends" error by moving a template into the conditional file, and fix it.- Interview question: "Why can
include:rulesnot see a variable defined in thevariables:section of the same file?" Hint: think about the order in which GitLab reads, merges and evaluates the configuration.
Include Merge Behavior
Why it matters. When a pipeline uses five included files and two of them define a job with the same name, which values win? When a job "mysteriously" has a variable you never set, where did it come from? Answering these questions requires knowing the exact merge order GitLab follows.
What it is. GitLab builds the final configuration in a fixed sequence:
1. Read .gitlab-ci.yml
2. Fetch every include entry (evaluating include:rules), and recursively their own includes
3. Merge the included files in the order they are listed: later files override earlier ones
4. Merge .gitlab-ci.yml on top: the main file overrides all included files
5. Resolve extends and !reference on the merged result (section 9.2)
6. Evaluate workflow:rules, then each job's rules, and create the pipeline
The merge in steps 3 and 4 is a deep merge: mappings (such as a job, variables or artifacts) are combined key by key, while lists and scalars are replaced. Section 9.2 describes the algorithm in detail.
# ci/test.yml (included)
pytest:
stage: test
image: python:3.12-slim
variables:
PYTEST_ADDOPTS: "-q"
DB_HOST: postgres
script:
- pytest
# .gitlab-ci.yml
include:
- local: ci/test.yml
pytest: # same job name: merged into the included job
variables:
DB_HOST: db.internal # overrides one variable
tags: [docker, large] # adds a keyword
# Merged result
pytest:
stage: test # from ci/test.yml
image: python:3.12-slim # from ci/test.yml
variables:
PYTEST_ADDOPTS: "-q" # from ci/test.yml
DB_HOST: db.internal # from .gitlab-ci.yml (main file wins)
script:
- pytest # from ci/test.yml
tags: [docker, large] # from .gitlab-ci.yml
This is how you customize included jobs without editing the included file: redefine the job in your own file with only the keys you want to change.
How specific keys combine.
| Key | Behavior across files |
|---|---|
| Jobs with the same name | Deep-merged. The main file, or the later include, wins per key |
stages | A list: replaced as a whole. The last definition wins, usually the main file |
Top-level variables | Merged by variable name |
default | Deep-merged like a job |
workflow:rules | A list: replaced as a whole by a later definition |
| YAML anchors | Never shared between files |
Limits.
| Limit | Default |
|---|---|
| Includes per pipeline, nested ones included | 150 (administrators can change it on self-managed instances) |
| Nesting depth | 100 levels |
| Time to resolve all includes | 30 seconds |
| Same file included more than once | Included once, but each occurrence counts toward the limit |
Debugging. The Full configuration tab of the pipeline editor shows the merged result, and the CI Lint API returns it too:
# Validate the stored configuration of project 12345 and print the merged YAML
curl --silent --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
"https://gitlab.example.com/api/v4/projects/12345/ci/lint?include_merged_yaml=true" \
| jq -r '.merged_yaml'
Key takeaways
- Includes are merged in listed order, then the main file is merged on top and wins.
- The merge is deep for mappings and replaces lists and scalars.
- Redefining an included job with only some keys is the standard way to customize it.
stagesandworkflow:rulesare lists: the last definition replaces earlier ones entirely.- Use the Full configuration tab or the Lint API's merged YAML when the result surprises you.
Practice
- (Beginner) Include a file that defines a
lintjob withimage,scriptand two variables. In.gitlab-ci.yml, override one variable and addallow_failure: true. Predict the merged job, then check it in the Full configuration tab.- (Intermediate) Include two files that both define
stagesand abuildjob with differentimageandscriptvalues. Swap their order in theincludelist and document how the result changes. Then fetch the merged YAML with the Lint API.- Interview question: "An included file defines
before_scriptwith three setup commands. A project redefinesbefore_scriptwith one extra command and the job breaks. Explain why." Hint: how lists behave in a deep merge compared with mappings.
<a id="92-configuration-reuse-patterns"></a>
9.2 Configuration Reuse Patterns
Once configuration is spread across files, jobs need a way to share it. This subchapter covers extends for job inheritance, !reference for reusing individual values, how to design libraries of hidden job templates, and the deep-merge algorithm that all of these mechanisms (and include) rely on.
extends Keyword
Why it matters. Ten deployment jobs that each repeat the same image, tags, before_script and rules drift apart over time: one gets a fix, the other nine do not. YAML anchors (section 2.1) help within a single file, but they cannot cross file boundaries and their merge is shallow. extends is GitLab's own inheritance mechanism, and it works across every included file.
What it is. extends names one or more jobs (usually hidden jobs, starting with a dot) whose configuration the current job inherits. It works like class inheritance in object-oriented languages: the child gets everything from the parent and overrides what it defines itself.
- It is resolved after all includes are merged, so the template can live in any included file.
- The merge is deep: mappings are merged key by key, and lists and scalars from the child replace the parent's (see Deep Merge Semantics below).
- A template can itself use
extends. GitLab allows up to 11 levels of inheritance, but more than three levels are hard to follow. - Any job can be extended, not only hidden ones.
.base:
image: alpine:3.20
tags: [docker]
retry: 1
variables:
LOG_LEVEL: info
.deploy:
extends: .base # a template that extends another template
stage: deploy
image: bitnami/kubectl:1.31 # overrides .base's image
variables:
KUBE_NAMESPACE: default
script:
- kubectl apply -n "$KUBE_NAMESPACE" -f k8s/
deploy-staging:
extends: .deploy
environment: staging
variables:
KUBE_NAMESPACE: staging # overrides one variable, keeps the others
The resolved job:
deploy-staging:
image: bitnami/kubectl:1.31 # .deploy beats .base
tags: [docker] # from .base
retry: 1 # from .base
stage: deploy # from .deploy
variables: # merged across all three levels
LOG_LEVEL: info # from .base
KUBE_NAMESPACE: staging # from the job
script:
- kubectl apply -n "$KUBE_NAMESPACE" -f k8s/ # from .deploy
environment: staging # from the job
Multiple parents. extends accepts a list. Parents are applied from left to right, so for the same key the last parent wins, and the job itself beats all parents. This makes small, single-purpose templates ("mixins") easy to combine:
.node:
image: node:22-slim
before_script: [npm ci]
.mr-and-main:
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
.large-runner:
tags: [docker, large]
lint:
extends: [.node, .mr-and-main]
script: [npm run lint]
e2e:
extends: [.node, .mr-and-main, .large-runner]
script: [npm run test:e2e]
Resolution order for e2e (later layers override earlier ones):
.node -> .mr-and-main -> .large-runner -> e2e (job keys)
first last parent highest priority
Interaction with other layers. extends sits between default and the job in the precedence order from section 2.4: job keys beat extends templates, which beat default. inherit controls only default and global variables, not what comes through extends.
Common errors.
| Symptom | Cause |
|---|---|
Validation error naming an unknown key in extends | Typo in the template name, or the template is in a file that was not included (for example, skipped by include:rules) |
Error about a circular dependency in extends | Two templates extend each other, directly or through a chain |
Job ignores the template's rules | The job defines its own rules, a list that replaces the template's |
Key takeaways
extendsinherits configuration from one or more jobs, usually hidden templates, and works across included files.- Mappings are merged deeply, and lists and scalars are replaced by the child.
- With several parents, the last one wins, and the job's own keys always win.
- Prefer flat compositions of small templates over deep inheritance chains (three levels at most).
Practice
- (Beginner) Refactor three jobs that share
image,tagsandbefore_scriptinto a.python-basetemplate used throughextends. Confirm with the Full configuration tab that the jobs are unchanged.- (Intermediate) Create
.awithimage: alpine,variables: {X: "1", Y: "1"}and.bwithimage: ubuntu,variables: {Y: "2"}. Predict the resolved job forextends: [.a, .b]and forextends: [.b, .a], each with its ownvariables: {Z: "3"}, then verify.- Interview question: "When would you use
extendsrather than YAML anchors with merge keys?" Hint: compare file scope, deep and shallow merges, the order in which multiple parents win, and readability.
!reference Tags
Why it matters. extends works per keyword: if a template and a job both define script or rules, the job's list replaces the template's. Real pipelines often need to combine lists: the shared registry login commands plus one extra step, or a shared set of rules plus one exception. !reference lets you pull a single value out of another job and place it exactly where you need it.
What it is. !reference [job, keyword, subkey, ...] is a custom YAML tag that GitLab replaces with the value found at that path in another job. The path starts with a job name (usually a hidden job), followed by a keyword and optionally deeper keys. It works across included files.
When a !reference that returns a list is placed inside another list (in script, before_script, after_script or rules), GitLab flattens it into the surrounding list. References can be nested up to 10 levels deep.
.registry-login:
script:
- echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
.vault-login:
script:
- export VAULT_TOKEN="$(vault write -field=token auth/jwt/login role=ci jwt=$VAULT_ID_TOKEN)"
build-image:
image: docker:27
services: [docker:27-dind]
script:
- !reference [.registry-login, script] # flattened into this list
- docker build -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA" .
- docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA"
deploy:
image: registry.example.com/tools/deployer:2.4
script:
- !reference [.vault-login, script]
- !reference [.registry-login, script] # combine several snippets
- ./deploy.sh
Reusing rules. Combining rule sets is the most valuable use, because rules are evaluated in order and the first match wins. You control that order explicitly:
.skip-docs-only-rules:
rules:
- if: $CI_COMMIT_TITLE =~ /^docs:/
when: never
.mr-and-main-rules:
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
unit-tests:
rules:
- !reference [.skip-docs-only-rules, rules] # checked first
- !reference [.mr-and-main-rules, rules]
script: [make test]
nightly-benchmarks:
rules:
- if: $CI_PIPELINE_SOURCE == "schedule" # a job-specific rule first
- !reference [.mr-and-main-rules, rules]
# flattened, this list is: schedule, merge_request_event, default branch
script: [make bench]
Reusing variables. A reference can return a whole mapping or a single value inside it:
.shared-vars:
variables:
API_URL: https://api.internal.example.com
TIMEOUT_SECONDS: "30"
integration-tests:
variables: !reference [.shared-vars, variables] # the whole mapping
smoke-tests:
variables:
BASE_URL: !reference [.shared-vars, variables, API_URL] # one value, under a new name
script: [./smoke.sh "$BASE_URL"]
Comparing the three reuse mechanisms.
YAML anchors (&, *, <<) | extends | !reference | |
|---|---|---|---|
| Resolved by | The YAML parser | GitLab | GitLab |
| Works across included files | No | Yes | Yes |
| Granularity | Whole node | Whole job | Any single value |
| Merge | Shallow (<<) | Deep | None: inserts the value as is |
| Combining lists | Not possible | Not possible (replaced) | Yes, by flattening |
| Standard YAML tools | Understand it | Understand it (plain key) | Reject the custom tag |
Because !reference is a custom tag, generic YAML linters and parsers such as Python's yaml.safe_load fail on it (section 2.1). Validate files that use it with CI Lint or the pipeline editor.
Key takeaways
!reference [job, keyword, ...]inserts one value from another job, including jobs from included files.- Inside
script,before_script,after_scriptandrules, referenced lists are flattened, which is how you append to shared lists. - Rule sets composed with
!referencekeep an explicit, readable order. - Use
extendsfor "this job is a kind of that job" and!referencefor "this job also needs that piece".
Practice
- (Beginner) Create a
.setuphidden job with twobefore_scriptcommands, and a job whosebefore_scriptruns both of them followed by a third command, using!reference.- (Intermediate) Put
.mr-and-main-rulesand.skip-docs-only-rulesin an includedci/rules.yml. Use them in three jobs in different orders, and verify with commits titleddocs: update READMEandfeat: add endpointwhich jobs run.- Interview question: "A job extends a template that defines
rules, and you need to add onewhen: neverrule in front of them without copying the template's rules. How?" Hint: the job'srulesreplaces the template's list, but nothing stops that new list from referencing the template's.
Hidden Job Templates
Why it matters. Section 2.2 introduced hidden jobs as places to store templates. When a team or a whole organization shares dozens of templates, the questions change: how to name them so that they do not collide, how to layer them, and how to make their requirements obvious to the people who use them. A template library is an API, and it deserves the same care.
Design principles.
- Namespace your templates. Hidden jobs with the same name from two included files are silently deep-merged, not reported as a conflict. A prefix such as
.acme-prevents accidental merges with templates from other sources. - Compose small templates instead of deep chains. Prefer
extends: [.acme-python, .acme-mr-rules, .acme-large-runner]over a five-level inheritance chain. Each template does one thing. - Make the contract explicit. Document which variables a template requires, and fail fast if they are missing.
- Keep templates incomplete on purpose. A template provides what is shared and leaves job-specific parts, such as
script, to the job.
Layered template library (platform/ci-templates)
.acme-base tags, retry, interruptible
│
├── .acme-python image, pip cache, before_script
├── .acme-node image, npm cache, before_script
└── .acme-docker docker image, dind service, registry login
Mixins (combined with extends lists):
.acme-mr-and-main-rules .acme-large-runner .acme-manual-on-main
# templates/docker.yml in platform/ci-templates
.acme-base:
tags: [docker]
retry:
max: 2
when: [runner_system_failure, stuck_or_timeout_failure]
interruptible: true
.acme-docker:
extends: .acme-base
image: docker:27
services: [docker:27-dind]
variables:
DOCKER_TLS_CERTDIR: /certs
before_script:
# Contract check: the job must set IMAGE_NAME, otherwise fail with a clear message
- ': "${IMAGE_NAME:?IMAGE_NAME must be set by the job using .acme-docker}"'
- echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
.acme-large-runner:
tags: [docker, large] # a list: replaces .acme-base's tags when listed later
# .gitlab-ci.yml in an application project
include:
- project: platform/ci-templates
ref: v3.2.0
file: /templates/docker.yml
build-api-image:
extends: [.acme-docker, .acme-large-runner]
stage: build
variables:
IMAGE_NAME: api # fulfills the template's contract
script:
- docker build -t "$CI_REGISTRY_IMAGE/$IMAGE_NAME:$CI_COMMIT_SHA" services/api
- docker push "$CI_REGISTRY_IMAGE/$IMAGE_NAME:$CI_COMMIT_SHA"
Removing something a template provides. Setting a key to null in the child removes it from the inherited configuration, while an empty value ([] or {}) behaves according to the merge rules:
build-docs:
extends: .acme-docker
services: null # removes the dind service completely
before_script: [] # replaces the template's list with an empty one: no setup commands
script: [make docs]
Choosing a reuse mechanism.
| Need | Use |
|---|---|
| Several jobs share most of their configuration | Hidden template with extends |
| Several jobs share one script snippet or rule set, combined with their own | !reference |
| Quick reuse inside a single file, with no cross-file needs | YAML anchors |
| Reuse across many projects, with parameters and versions | CI/CD components (9.3) |
Key takeaways
- Treat a template library as an API: namespaced names, documented contracts and stable versions.
- Same-named hidden jobs from different files merge silently. Prefixes prevent collisions.
- Compose small single-purpose templates with
extendslists instead of deep chains. - Fail fast with a clear message when a required variable is missing (
${VAR:?message}).
Practice
- (Beginner) Write
.acme-nodeand.acme-mr-and-main-rulestemplates and use them together inlintandtestjobs.- (Intermediate) Include two files that both define
.basewith differentimageandvariables. Observe the silent merge in the Full configuration tab, then rename both with distinct prefixes and fix the jobs that use them.- Interview question: "How would you design a CI template library for 50 teams so that template changes are safe to roll out?" Hint: naming, small composable templates, explicit contracts, versioned refs, and a test pipeline in the template project.
Deep Merge Semantics
Why it matters. include, extends and job overrides of included jobs all combine configuration with the same algorithm. Most "my job lost its setup commands" or "where did this artifact setting come from" problems come from not knowing exactly how it works. Learn the algorithm once and you can predict every resolved job.
The algorithm. Merging a child (the overriding side) into a parent:
- If both values are mappings, merge them key by key, recursively applying these rules.
- Otherwise (lists, strings, numbers, booleans), the child's value replaces the parent's.
- A key set to
nullin the child is removed from the result. - With several parents, merge them from left to right, then merge the job on top.
The same logic in Python:
def deep_merge(parent, child):
"""Return the result of merging child (higher priority) into parent."""
if not (isinstance(parent, dict) and isinstance(child, dict)):
return child # lists and scalars: the child replaces the parent
result = dict(parent)
for key, value in child.items():
if value is None:
result.pop(key, None) # null removes the key
elif key in result:
result[key] = deep_merge(result[key], value) # recurse into nested values
else:
result[key] = value
return result
A worked example.
.template:
image:
name: registry.example.com/tools/terraform:1.9
entrypoint: [""]
variables:
TF_ROOT: infra
TF_IN_AUTOMATION: "true"
artifacts:
paths: [infra/plan.cache]
expire_in: 1 week
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
script: [terraform plan -out=plan.cache]
plan-prod:
extends: .template
image: registry.example.com/tools/terraform:1.10 # string replaces the mapping
variables:
TF_ROOT: infra/prod
artifacts:
paths: [infra/prod/plan.cache] # list replaced, expire_in kept
rules:
- if: $CI_COMMIT_TAG # list replaced: the branch rule is gone
# Resolved
plan-prod:
image: registry.example.com/tools/terraform:1.10 # entrypoint [""] is lost with the mapping
variables:
TF_ROOT: infra/prod # child value
TF_IN_AUTOMATION: "true" # kept from the parent
artifacts:
paths: [infra/prod/plan.cache] # child list
expire_in: 1 week # kept from the parent mapping
rules:
- if: $CI_COMMIT_TAG # only the child's rule
script: [terraform plan -out=plan.cache]
The image line is a classic surprise. Writing image: {name: registry.example.com/tools/terraform:1.10} instead would merge with the parent's mapping and keep entrypoint.
Empty values compared with null.
| Child value | Parent variables: {A: "1"} becomes | Why |
|---|---|---|
variables: {B: "2"} | {A: "1", B: "2"} | Mappings merge |
variables: {} | {A: "1"} | Merging an empty mapping adds nothing |
variables: null | No variables key | null removes the key |
script: [] (parent has a script) | Empty list | Lists are replaced |
Who merges how. Not every layer uses the deep merge:
| Combination | Behavior |
|---|---|
| Included files and the main file | Deep merge, later file wins |
extends templates and the job | Deep merge, job wins |
YAML merge key <<: *anchor | Shallow: a key in the job replaces the anchor's whole value |
default and the job | No merge: a job keyword replaces the default keyword entirely (section 2.4) |
Global variables and job variables | Merged by name at runtime, the job wins |
The difference between shallow and deep merges, side by side:
.vars: &vars
variables: {A: "1", B: "1"}
anchored:
<<: *vars
variables: {B: "2"} # result: {B: "2"} only, A is lost (shallow)
.vars-template:
variables: {A: "1", B: "1"}
extended:
extends: .vars-template
variables: {B: "2"} # result: {A: "1", B: "2"} (deep)
Key takeaways
- Mappings merge recursively. Lists and scalars are replaced.
nullremoves a key. rules,script,tags,needsandartifacts:pathsare lists: a child that defines them replaces them entirely.- Changing a keyword from mapping form to string form (like
image) drops the parent's nested keys. defaultand YAML merge keys do not deep-merge.includeandextendsdo.
Practice
- (Beginner) For a template with
cache: {key: deps, paths: [.npm/], policy: pull-push}and a job withcache: {policy: pull}, write the resolvedcacheand verify it in the Full configuration tab.- (Intermediate) Extend the Python
deep_mergefunction to support a list of parents, and test it against three real templates and a job from your pipeline. Compare its output with GitLab's merged YAML from the Lint API.- Interview question: "A job extends a template whose
artifactsmapping includesreports: junit: report.xml. The job addsartifacts: paths: [coverage/]. Is the JUnit report still uploaded? What if the job had used a YAML merge key instead?" Hint: apply the deep-merge rules to nested mappings, then compare with a shallow merge.
<a id="93-cicd-components-and-catalog"></a>
9.3 CI/CD Components and Catalog
Includes and templates share YAML, but they do not define a contract. Users have to read the file to learn which variables to set, nothing is typed or validated, and there is no standard way to find or version shared configuration. CI/CD components package a reusable unit of configuration with typed inputs and semantic versions, and the CI/CD Catalog makes them discoverable. This subchapter covers how to write, structure, release, publish and test them.
spec:inputs
Why it matters. Templates are usually configured with CI/CD variables, which have three weaknesses. They are untyped strings. They leak into every job as environment variables, so two included templates that both read IMAGE interfere with each other. And they cannot change the structure of the configuration: variables are not expanded in job names, and only in a limited set of keywords. A template that should create a job named api-build in stage package for one caller and worker-build in stage build for another cannot be written with variables. Inputs solve all three problems.
What it is. A configuration file can start with a header that declares inputs under spec:inputs, separated from the body by ---. In the body, $[[ inputs.name ]] is replaced with the input's value while GitLab builds the configuration, before rules are evaluated and before any job runs. Think of the file as a function: the header is its signature, the body is its implementation, and include is the function call.
Section 5.3 covered inputs for the main pipeline. Here they parameterize included files, which is their main purpose:
# templates/docker-build.yml in the project acme/ci-components
spec:
inputs:
job_prefix:
default: docker
description: Prefix for job names, so the component can be included more than once
stage:
default: build
description: Stage of the build job
image_name:
description: Image name below $CI_REGISTRY_IMAGE (required, because it has no default)
context:
default: .
description: Docker build context directory
---
"$[[ inputs.job_prefix ]]-build": # inputs also work in job names
stage: $[[ inputs.stage ]]
image: docker:27
services: [docker:27-dind]
variables:
DOCKER_TLS_CERTDIR: /certs
script:
- echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
- docker build -t "$CI_REGISTRY_IMAGE/$[[ inputs.image_name ]]:$CI_COMMIT_SHA" "$[[ inputs.context ]]"
- docker push "$CI_REGISTRY_IMAGE/$[[ inputs.image_name ]]:$CI_COMMIT_SHA"
Callers pass values with inputs on the include entry. The same file can be included several times with different inputs:
# .gitlab-ci.yml in a monorepo
stages: [build, test, package]
include:
- component: $CI_SERVER_FQDN/acme/ci-components/docker-build@2.1.0
inputs:
job_prefix: api
image_name: api
context: services/api
- component: $CI_SERVER_FQDN/acme/ci-components/docker-build@2.1.0
inputs:
job_prefix: worker
stage: package
image_name: worker
context: services/worker
# Result: jobs "api-build" (stage build) and "worker-build" (stage package)
inputs is not limited to components: include:local, include:project and include:remote entries can pass inputs to any file that has a spec:inputs header.
When values are resolved.
pipeline creation job execution
────────────────────────────────────────────────────────────── ──────────────────────
fetch includes -> validate inputs -> interpolate $[[ inputs ]] runner expands $VARIABLES
-> merge files -> extends/!reference -> rules in the job's shell
Inputs $[[ inputs.x ]] | Variables $X | |
|---|---|---|
| Resolved | While the configuration is built | Mostly at runtime, by the runner or the shell |
| Scope | Only the file that declares them | Every job, as environment variables |
| Types | string, number, boolean, array | Strings only |
| Validation | options, regex, required values | None |
| Usable in job names and any keyword | Yes | No |
| Can be overridden after the pipeline starts | No | Yes, by higher-precedence sources |
Rules for the header.
- The header is a separate YAML document:
spec:first, then---, then the body. - Inputs without a
defaultare required. A missing required input, or an input that the header does not declare, stops pipeline creation with an error. - Interpolation only works in the file that declares the input. A file it includes needs its own header and receives values through its own
include:inputs. - Interpolation functions (
expand_vars,truncate,posix_escape) work as described in section 5.3.
Key takeaways
spec:inputsdeclares a file's parameters in a header above---.$[[ inputs.name ]]interpolates them while the configuration is built.- Inputs are scoped to their file, typed and validated, unlike variables.
- Inputs can change structure: job names, stages,
needs,rulesand any other keyword. - Callers pass inputs with
include:inputs, which works for components and for local, project and remote files.
Practice
- (Beginner) Convert a local file
ci/lint.ymlthat reads aLINT_IMAGEvariable into a file with aspec:inputsheader and animageinput. Include it from.gitlab-ci.ymlwith a custom value.- (Intermediate) Add a
job_prefixinput and include the file twice, forfrontend/andbackend/. Then deliberately omit a required input and pass an undeclared input, and record both pipeline creation errors.- Interview question: "Why can inputs be used in a job name while CI/CD variables cannot?" Hint: compare the moment each is resolved with the moment GitLab decides which jobs exist.
Input Types and Validation
Why it matters. A component used by fifty projects receives fifty different interpretations of its parameters: production and prod, 3.12 and python3.12, "true" and "yes". Without validation, these mistakes surface at runtime, deep inside a failing job. Typed, validated inputs reject them at pipeline creation, with a message that names the input.
Types and validation keywords.
| Keyword | Purpose |
|---|---|
type | string (the default), number, boolean or array |
default | Value used when the caller passes nothing. No default makes the input required |
options | A list of allowed values |
regex | A regular expression that string values must match |
description | Shown in the CI/CD Catalog and to people who read the file |
A complete example, which the following topics reuse as the python-test component:
# templates/python-test.yml in acme/ci-components
spec:
inputs:
job_prefix:
default: python
stage:
default: test
python_version:
default: "3.12"
regex: ^3\.(1[0-3])$ # 3.10 to 3.13 only
description: Python minor version used for the job image
root_dir:
default: .
description: Directory containing pyproject.toml
parallel:
type: number
default: 2
description: Number of parallel test shards
coverage_required:
type: boolean
default: true
report_level:
default: short
options: [short, long, line, no] # passed to pytest --tb
rules:
type: array
default: # an array of mappings
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
---
"$[[ inputs.job_prefix ]]-pytest":
stage: $[[ inputs.stage ]]
image: python:$[[ inputs.python_version ]]-slim # embedded in a string: becomes text
parallel: $[[ inputs.parallel ]] # whole value: stays a number
rules: $[[ inputs.rules ]] # whole value: stays an array
variables:
COVERAGE_REQUIRED: "$[[ inputs.coverage_required ]]" # bridged into a variable as "true"/"false"
script:
- cd "$[[ inputs.root_dir ]]"
- pip install -e ".[test]"
- |
pytest --tb=$[[ inputs.report_level ]] --junitxml=report.xml \
--splits "$CI_NODE_TOTAL" --group "$CI_NODE_INDEX" --cov
- if [ "$COVERAGE_REQUIRED" = "true" ]; then coverage report --fail-under=80; fi
artifacts:
reports:
junit: $[[ inputs.root_dir ]]/report.xml
Typed interpolation. If $[[ inputs.x ]] is the entire YAML value, the input keeps its type: a number stays a number and an array becomes a real YAML list. If it is embedded in a longer string, the value is converted to text. This is what makes parallel: $[[ inputs.parallel ]] and rules: $[[ inputs.rules ]] valid.
What validation catches. All checks run before the pipeline is created. A failing check creates no pipeline and shows an error that names the input:
| Caller passes | Result |
|---|---|
python_version: "2.7" | Rejected: does not match the regex |
report_level: verbose | Rejected: not one of the allowed options |
parallel: "four" | Rejected: not a number |
coverage_required: "yes" | Rejected: not a boolean |
paralel: 4 (typo) | Rejected: unknown input name |
| Nothing for an input without a default | Rejected: required value missing |
Designing good inputs.
- Give every optional input a sensible
default, so the simplest usage is oneincludeline. - Make
stagean input. Callers' stage lists differ. - Add a
job_prefixinput (or similar) whenever a component may be included more than once. - Prefer
optionsandregexover documentation-only constraints. - Prefer
booleanandnumberover strings like"true"and"4". - Keep the number of inputs small. A component with thirty inputs is a configuration language, not a building block.
Key takeaways
- Input types are
string,number,booleanandarray.optionsandregexrestrict values, and nodefaultmeans required. - A whole-value interpolation keeps the type, while an embedded interpolation becomes a string.
- Validation failures prevent pipeline creation and name the offending input, which is far easier to debug than a failing job.
- Design inputs for the common case: defaults everywhere,
stageandjob_prefixas inputs, few parameters overall.
Practice
- (Beginner) Add an
environmentinput withoptions: [dev, staging, production]to a deployment file and include it with a valid and an invalid value. Record the error.- (Intermediate) Add a
needsinput of typearraywith default[]to thepython-testcomponent and use it as the job'sneeds. Include the component once with the default and once withneeds: [build], and verify the job graph in both pipelines.- Interview question: "Why validate component parameters with
optionsandregexinspec:inputsinstead of checking them at the start of the job script?" Hint: compare when each check fails, who sees the error and where, and how much runner time is spent before the failure.
Component Project Structure
Why it matters. GitLab finds components by convention, not by configuration. A file in the wrong place is simply not a component, and a component that relies on files in its own repository fails in every project that uses it. Knowing the expected layout avoids both problems.
What it is. A component project is an ordinary GitLab project with a templates/ directory at its root. Each component is either:
- a single file,
templates/<name>.yml, or - a directory,
templates/<name>/template.yml, which can hold additional files such as a component README.
A project can contain many components (up to 100 in current GitLab versions).
acme/ci-components
├── README.md # required for the catalog: documents every component
├── LICENSE.md
├── .gitlab-ci.yml # tests and releases the components (see Testing Components)
├── templates/
│ ├── python-test.yml # component "python-test"
│ ├── secret-scan.yml # component "secret-scan"
│ └── docker-build/
│ ├── template.yml # component "docker-build"
│ └── README.md # documentation only: never included in pipelines
├── tests/
│ └── fixtures/python-app/ # sample project used by the component's own tests
└── images/
└── python-test/Dockerfile # tools used by the component, shipped as a container image
Referencing a component.
$CI_SERVER_FQDN / acme/ci-components / python-test @ 1.4.0
└── instance ────┘ └── project path ─┘ └ component ┘ └ version
$CI_SERVER_FQDN is a predefined variable containing the instance host name (and port, if any). Using it instead of a hard-coded host keeps the configuration portable, for example after a migration to a new domain.
Only the YAML travels. When a project includes a component, GitLab copies the component's YAML into the pipeline. The jobs run in the consuming project's checkout, so scripts, configuration files or Dockerfiles stored next to the component do not exist there:
# Broken: scripts/run-tests.sh exists in acme/ci-components, not in the consuming project
python-pytest:
script:
- ./scripts/run-tests.sh
# Fixed: ship the tooling in an image that is versioned with the component
python-pytest:
image: $CI_REGISTRY/acme/ci-components/python-test:1.4.0
script:
- run-tests # installed in the image
Best practices for component content.
| Do | Avoid | Why |
|---|---|---|
Make stage an input | Hard-coding a stage the caller may not have | A missing stage fails pipeline creation |
| Prefix job names with an input | Fixed, generic names like test | Collisions are silently deep-merged with the caller's jobs |
| Define everything at job level | Global default, top-level variables, workflow | Global keywords change every job in the caller's pipeline |
Use predefined variables ($CI_PROJECT_DIR, $CI_COMMIT_SHA) | Assuming project-specific variables exist | The component runs in many different projects |
| Ship tools in versioned images | Relying on files in the component repository | Only the YAML is included |
| Document each component in the README | Leaving inputs undocumented | The README is the catalog page |
Key takeaways
- Components live in
templates/as<name>.ymlor<name>/template.yml. The file or directory name is the component name. - A component is referenced as
<host>/<project path>/<component>@<version>. Use$CI_SERVER_FQDNfor the host. - Only the component's YAML is included. Anything else it needs must come from an image or a download.
- Components should be self-contained: job-level configuration only, an input for the stage, prefixed job names.
Practice
- (Beginner) Create a component project with a single-file component
hellothat prints a greeting passed as an input, and include it from another project using a commit SHA as the version.- (Intermediate) Convert
hellointo a directory component with its own README. Then make it call a shell script stored in the component project, observe the failure in the consuming project, and fix it by shipping the script in an image built by the component project.- Interview question: "Why should a component avoid top-level keywords such as
defaultorvariables?" Hint: the component's YAML is merged into someone else's pipeline. Think about which of their jobs those keywords would affect.
Versioning and Releasing Components
Why it matters. A component is a dependency. Consumers need to know whether an update is safe to take, and maintainers need a way to ship breaking changes without breaking everyone at once. Semantic versioning and the release process give both sides that control.
Semantic versioning. Component versions use MAJOR.MINOR.PATCH. For CI components, "breaking" means anything that can make an existing caller's pipeline fail or behave differently:
| Change | Version bump |
|---|---|
| Fix a bug in a script without changing behavior otherwise | PATCH (1.4.0 to 1.4.1) |
| Add an optional input with a default, add an optional job | MINOR (1.4.1 to 1.5.0) |
| Remove or rename an input, add a required input, rename a job, change a default, change the default stage | MAJOR (1.5.0 to 2.0.0) |
Version references.
| Reference | Resolves to | Moves automatically | Typical use |
|---|---|---|---|
@1.4.2 | That exact release (tag) | No | Production, with a bot to propose upgrades |
@1.4 | The latest 1.4.x release | Patches only | Receive fixes automatically |
@1 | The latest 1.x.y release | Minors and patches | Receive features, but no breaking changes |
@~latest | The latest published release | Everything, including majors | Experiments and demos only |
@<commit SHA> | That commit | No | Maximum control, testing unreleased code |
@main | The branch's current commit | Every merge | Developing the component itself |
Partial versions (@1, @1.4) and @~latest only resolve to releases published in the CI/CD Catalog. If a branch, a tag and a commit SHA share a name, GitLab prefers the SHA, then the tag, then the branch.
Releasing. A version is published to the catalog when a release is created by the release keyword in a CI/CD job (releases created through the UI or API are not published to the catalog), for a tag that is a valid semantic version such as 1.5.0:
# .gitlab-ci.yml in acme/ci-components (the test jobs are shown in Testing Components)
stages: [test, release]
create-release:
stage: release
image: registry.gitlab.com/gitlab-org/cli:latest # provides glab for the release keyword
rules:
- if: $CI_COMMIT_TAG =~ /^\d+\.\d+\.\d+$/ # semantic version tags only
script:
- echo "Releasing components version $CI_COMMIT_TAG"
release:
tag_name: $CI_COMMIT_TAG
description: |
Components in $CI_PROJECT_PATH, version $CI_COMMIT_TAG.
See CHANGELOG.md for breaking changes and upgrade notes.
Release flow
MR with change ──> review + component tests ──> merge to main
│
maintainer pushes tag 1.5.0 ──┘
│
tag pipeline: tests ──> create-release (release keyword)
│
CI/CD Catalog lists 1.5.0 ────┘
│
consumers on @1 or @1.5 pick it up at their next pipeline;
consumers on @1.4.2 stay put until they bump the version
Managing breaking changes.
- Before removing an input in the next major version, keep it working in a minor release and print a deprecation warning from the job script.
- Document upgrade steps in the release description or a
CHANGELOG.md. - Keep fixing the previous major version for a while (for example, from a
1.xmaintenance branch) so consumers can upgrade on their own schedule. - Protect version tags (Settings > Repository > Protected tags, pattern such as
*.*.*) so that only maintainers can publish.
Key takeaways
- Version components with semantic versioning. Any change that can break a caller's pipeline is a major change.
- Exact versions are stable,
@1and@1.4follow compatible updates, and@~latestfollows everything. - Partial versions and
~latestonly work for releases published in the catalog. - Catalog releases come from the
releasekeyword in a tag pipeline, with a semantic version tag.
Practice
- (Beginner) Add the
create-releasejob to your component project, push tag1.0.0, and verify that the release appears. Include the component with@1.0.0,@1and@~latest.- (Intermediate) Release
1.1.0with a new optional input, then2.0.0that renames an existing input. Show which of three consuming projects, pinned to@1.0.0,@1and@~latest, break at their next pipeline.- Interview question: "Your team maintains a deployment component used by 80 projects, and you need to rename its required
envinput toenvironment. How do you roll this out?" Hint: a deprecation period in a minor release, a major release, the version references consumers use, and communication.
CI/CD Catalog
Why it matters. Shared configuration is only useful if people can find it. Without a catalog, teams rebuild the same Docker build or deployment jobs because they do not know a maintained version already exists. The CI/CD Catalog is the instance-wide index of published components, with documentation, versions and usage information on one page.
What it is. The catalog lists every catalog project the current user can see. Open it from Search or go to > Explore > CI/CD Catalog, or from the Browse CI/CD Catalog button in the pipeline editor (section 1.3). Each catalog entry shows:
| Element | Source |
|---|---|
| Name and description | The project's name and description |
| Documentation | The project's README.md |
| Components and their inputs | Generated from each component's spec:inputs |
| Versions | Releases created by the release keyword |
| Usage | How many projects use the components |
| Badges | GitLab-maintained, partner or verified creator components |
Publishing a project.
- Add a project description and a
README.mdthat documents each component, its inputs and an example. - Place at least one component in
templates/. - As a project Owner, go to Settings > General > Visibility, project features, permissions and turn on CI/CD Catalog project.
- Create a release with the
releasekeyword in a tag pipeline (previous topic).
A good README section for one component:
## python-test
Runs pytest with coverage and JUnit reports, split across parallel jobs.
```yaml
include:
- component: $CI_SERVER_FQDN/acme/ci-components/python-test@1
inputs:
python_version: "3.13"
root_dir: backend
```
| Input | Type | Default | Description |
|---|---|---|---|
| `python_version` | string | `3.12` | Python minor version, 3.10 to 3.13 |
| `parallel` | number | `2` | Number of test shards |
Visibility and access. Catalog entries follow project visibility: a private catalog project is listed only for its members, an internal one for every signed-in user. Including a component still requires read access to its project, exactly as with include:project.
Components on GitLab.com and self-managed instances. GitLab maintains official components in the components group on GitLab.com, for example security scanners and OpenTofu. A self-managed instance has its own catalog and cannot include components from GitLab.com directly. To use one, mirror its project into your instance, enable it as a catalog project, and create releases there.
Adopting a component safely. A component's jobs run with your project's variables and secrets. Before adopting one:
- Read the component's YAML for the version you plan to use, not just the README.
- Check the maintainer, badges and release history.
- Pin an exact version or a major version, never
~latestin production. - Check the Full configuration tab to see exactly which jobs and keywords it adds.
Key takeaways
- The CI/CD Catalog indexes published component projects, with README, input tables, versions and usage.
- Publishing needs a description, a README, components in
templates/, the catalog setting and areleasekeyword release. - Catalog visibility follows project visibility. Using a component needs read access to its project.
- Treat third-party components like any dependency: review, pin and monitor.
Practice
- (Beginner) Browse the CI/CD Catalog on your instance or on GitLab.com, pick one component, and list its inputs, latest version and the jobs it creates.
- (Intermediate) Publish your component project from the previous topics to the catalog, with a README that documents every input. Ask a colleague to add it to their project using only the catalog page.
- Interview question: "How would you introduce a curated internal catalog of CI components in a company where every team currently writes its own pipelines?" Hint: ownership, documentation, versioning policy, badges or naming for trusted components, and measuring adoption through usage data.
Testing Components
Why it matters. A broken component release breaks every project that uses it. Component code is YAML that only shows its behavior when GitLab builds a pipeline from it, so the only reliable test is to have GitLab do exactly that, in the component project's own pipeline, before a release is created.
Testing levels.
| Level | Question | Technique |
|---|---|---|
| Configuration | Does the component produce valid configuration with typical inputs? | Include it at $CI_COMMIT_SHA in the project's own pipeline |
| Structure | Does it create the expected jobs? | Query the Jobs API from a verification job |
| Behavior | Do its jobs succeed on a realistic project? | Run them against fixtures stored in the component project |
| Contract | Are invalid inputs rejected? | Send invalid configurations to the CI Lint API and expect failure |
Self-testing pipeline. Because the component project's pipeline runs in the component project, the components' jobs can use test fixtures from its repository:
# .gitlab-ci.yml in acme/ci-components
stages: [test, verify, release]
include:
# Instantiate the component from the commit under test, against a fixture project
- component: $CI_SERVER_FQDN/$CI_PROJECT_PATH/python-test@$CI_COMMIT_SHA
inputs:
job_prefix: smoke
root_dir: tests/fixtures/python-app
parallel: 2
rules:
- when: always # run in every pipeline of this project
verify-jobs-created:
stage: verify
image: alpine:3.20
script:
- apk add --no-cache curl jq
# List this pipeline's jobs and check that both shards of the component job exist
- |
curl --silent --fail --header "PRIVATE-TOKEN: $API_READ_TOKEN" \
"$CI_API_V4_URL/projects/$CI_PROJECT_ID/pipelines/$CI_PIPELINE_ID/jobs?per_page=100" > jobs.json
- jq -e 'map(.name) | index("smoke-pytest 1/2") and index("smoke-pytest 2/2")' jobs.json
reject-invalid-inputs:
stage: verify
image: alpine:3.20
script:
- apk add --no-cache curl jq
# Build a configuration with an invalid python_version and ask CI Lint to validate it
- |
cat > invalid.yml <<EOF
include:
- component: $CI_SERVER_FQDN/$CI_PROJECT_PATH/python-test@$CI_COMMIT_SHA
inputs:
python_version: "2.7"
EOF
- jq --null-input --rawfile content invalid.yml '{content: $content}' > body.json
- |
curl --silent --fail --header "PRIVATE-TOKEN: $API_TOKEN" \
--header "Content-Type: application/json" --data @body.json \
"$CI_API_V4_URL/projects/$CI_PROJECT_ID/ci/lint" > lint.json
- jq -e '.valid == false' lint.json # the test passes only if GitLab rejects the input
- jq -r '.errors[]' lint.json # show the rejection message in the log
create-release:
stage: release # runs only after test and verify stages succeed
image: registry.gitlab.com/gitlab-org/cli:latest
rules:
- if: $CI_COMMIT_TAG =~ /^\d+\.\d+\.\d+$/
script:
- echo "Releasing $CI_COMMIT_TAG"
release:
tag_name: $CI_COMMIT_TAG
description: "Release $CI_COMMIT_TAG of $CI_PROJECT_PATH"
API_READ_TOKEN (scope read_api) and API_TOKEN (scope api, needed by the Lint endpoint) are masked project variables holding project access tokens.
Testing several input combinations. Each include with different inputs and a different job_prefix is another test case in the same pipeline. For combinations that conflict with each other (for example, different stage values that need different stages lists), put each case in a small file under tests/ and run it as a child pipeline with trigger:include:local. Each child pipeline then builds the component independently.
Key takeaways
- Test components by having GitLab build a pipeline from them at
$CI_COMMIT_SHAin the component project's own pipeline. - Verify structure through the Jobs API, behavior through fixtures, and contracts through the CI Lint API.
- Run the release job only after all tests pass, so a broken version never reaches the catalog.
- Use separate
job_prefixvalues or child pipelines to test several input combinations.
Practice
- (Beginner) Add a self-test to your component project that includes the component at
$CI_COMMIT_SHAwith default inputs. Break the component's YAML on a branch and observe the pipeline creation error.- (Intermediate) Add
verify-jobs-createdandreject-invalid-inputsjobs for your own component, plus a second test case with different inputs and a differentjob_prefix. Make the release job run only on semantic version tags after all tests succeed.- Interview question: "How do you test a CI/CD component before releasing it, given that it has no code of its own to unit-test?" Hint: GitLab itself is the interpreter. Consider configuration validity, created jobs, job behavior on fixtures and rejected inputs.
<a id="94-built-in-templates"></a>
9.4 Built-in Templates
GitLab ships a library of CI templates inside the product. Some are starting points meant to be copied and adapted, others are designed to be included and tuned with variables, most notably the security scanners. This subchapter covers both kinds and the techniques for customizing included templates without forking them.
Language and Framework Templates
Why it matters. Starting a pipeline from a blank file means rediscovering the same basics for every language: which image, how to cache dependencies, how to run the tests. GitLab's language templates give you a working starting point in seconds. Knowing what kind of template you are looking at, and whether to copy or include it, avoids a common mistake.
Two kinds of templates. The template directory (lib/gitlab/ci/templates/ in the GitLab source code) contains two different kinds of files:
| Pipeline templates | Job templates | |
|---|---|---|
| Location | Root of the directory: Python.gitlab-ci.yml, Nodejs.gitlab-ci.yml, Go.gitlab-ci.yml, Rust.gitlab-ci.yml, Maven.gitlab-ci.yml, Android.gitlab-ci.yml | Subdirectories: Jobs/, Security/, Workflows/, Verify/ |
| Content | A complete pipeline, often with its own stages, image and cache | Jobs or settings designed to be added to an existing pipeline |
| Intended use | Copy into .gitlab-ci.yml, then adapt | Include with include:template, then tune with variables |
| Updates | None after copying: the file is yours | Follow your GitLab version |
Including a pipeline template next to your own configuration usually causes conflicts: its stages, default settings and generic job names merge with yours. Copy pipeline templates; include job templates.
Copying a pipeline template. When you create .gitlab-ci.yml in the web editor, or open the pipeline editor in a project without one, a template selector offers the pipeline templates. The templates API returns the same files (section 9.1). Treat the result as a first draft:
| Typical content of a starter template | What to change |
|---|---|
image: python:latest or similar | Pin a specific version, such as python:3.12-slim |
| A generic cache for the package manager | Key the cache on the lock file (section 8.2) |
| Example jobs for tests, linting or deployment | Delete what you do not need, rename the rest |
No rules or workflow | Add workflow:rules to avoid duplicate pipelines (section 4.4) |
Useful job templates. Some smaller job templates are worth including directly:
| Template | Purpose |
|---|---|
Workflows/MergeRequest-Pipelines.gitlab-ci.yml | workflow:rules for merge request pipelines, tags and the default branch |
Workflows/Branch-Pipelines.gitlab-ci.yml | workflow:rules for branch pipelines and tags only |
Verify/Accessibility.gitlab-ci.yml, Verify/Browser-Performance.gitlab-ci.yml, Verify/Load-Performance-Testing.gitlab-ci.yml | Quality checks covered in chapter 10 |
Auto-DevOps.gitlab-ci.yml and Jobs/Build.gitlab-ci.yml, Jobs/Deploy.gitlab-ci.yml, ... | Auto DevOps and its building blocks (section 12.6) |
# A small Python project: workflow from a template, jobs written by hand
include:
- template: Workflows/MergeRequest-Pipelines.gitlab-ci.yml
default:
image: python:3.12-slim
test:
script:
- pip install -r requirements.txt
- pytest
GitLab is moving reusable functionality from templates to CI/CD components. Some templates have already been deprecated and removed in favor of components: the Terraform templates, for example, were replaced by the OpenTofu component. Before building on a template, check whether a maintained component exists in the CI/CD Catalog.
Key takeaways
- Root-level templates such as
Python.gitlab-ci.ymlare complete starter pipelines: copy them, do not include them. - Templates in
Jobs/,Security/,Workflows/andVerify/are designed forinclude:template. - Starter templates are first drafts: pin images, fix cache keys, remove unused jobs and add workflow rules.
- Prefer a maintained CI/CD component over a template when one exists.
Practice
- (Beginner) Create
.gitlab-ci.ymlfrom the starter template for your project's language. List three things you would change before using it in production, and change them.- (Intermediate) Fetch the content of the
Nodejsstarter template through the templates API, then includeWorkflows/MergeRequest-Pipelines.gitlab-ci.ymlin a project that uses it. Explain from the Full configuration tab which pipelines will now be created.- Interview question: "Why do GitLab's language templates work well as a starting point but poorly as an
include?" Hint: what a complete pipeline defines at the top level, and how those keys merge with yours.
Security Scanner Templates
Why it matters. Security scanning is the most common use of included templates. One include line adds a maintained scanner with dozens of language-specific analyzers, and GitLab keeps them updated with each release. Chapter 11 covers what each scanner finds and how to manage the results. This topic covers how the templates plug into your pipeline.
The main templates.
| Template | Jobs it adds | Stage | Notes |
|---|---|---|---|
Jobs/SAST.gitlab-ci.yml | semgrep-sast and other analyzers, chosen by the languages in the repository | test | Source code analysis |
Jobs/SAST-IaC.gitlab-ci.yml | kics-iac-sast | test | Terraform, Kubernetes manifests and other infrastructure code |
Jobs/Secret-Detection.gitlab-ci.yml | secret_detection | test | Credentials committed to the repository |
Jobs/Dependency-Scanning.gitlab-ci.yml | One job per supported package manager | test | Ultimate: vulnerable dependencies |
Jobs/Container-Scanning.gitlab-ci.yml | container_scanning | test | Scans the image named in CS_IMAGE |
Security/DAST.gitlab-ci.yml | dast | dast | Ultimate: scans a running application |
How they work. Each template defines hidden base jobs and visible analyzer jobs. The analyzer jobs:
- run in the
teststage (DAST indast), - decide with
ruleswhether they apply, for example by checking that relevant files exist and that a*_DISABLEDvariable is not set, - are configured through CI/CD variables,
- upload a security report artifact that GitLab reads (chapter 11),
- usually set
allow_failure: true, so a scanner problem does not block the pipeline.
stages: [build, test, deploy] # "test" must exist for the scanner jobs
include:
- template: Jobs/SAST.gitlab-ci.yml
- template: Jobs/Secret-Detection.gitlab-ci.yml
- template: Jobs/Container-Scanning.gitlab-ci.yml
variables:
SAST_EXCLUDED_PATHS: "spec, test, tests, tmp, node_modules" # skip test and vendored code
CS_IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA # image built in the build stage
build-image:
stage: build
image: docker:27
services: [docker:27-dind]
script:
- echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
- docker build -t "$CS_IMAGE" .
- docker push "$CS_IMAGE"
The stage pitfall. The scanner jobs expect a test stage. A pipeline with custom stages such as [build, verify, deploy] fails at creation with an error saying that the chosen stage does not exist. Either keep a test stage or override stage for each scanner job (next topic).
Component equivalents. The main scanners are also published as components in the components group on GitLab.com. They take inputs instead of variables for common settings and can be pinned to a version:
stages: [build, verify, deploy] # no "test" stage needed: the stage is an input
include:
- component: gitlab.com/components/sast/sast@~latest # pin a version in real projects
inputs:
stage: verify
- component: gitlab.com/components/secret-detection/secret-detection@~latest
inputs:
stage: verify
Enforcing scans. An include in .gitlab-ci.yml can be removed by anyone who can edit that file. To guarantee that scans run in every project of a group, use security policies (scan execution or pipeline execution policies, section 11.3) instead of relying on each project's includes.
Key takeaways
Jobs/SAST,Jobs/Secret-Detection,Jobs/Dependency-Scanning,Jobs/Container-ScanningandSecurity/DASTadd maintained scanner jobs with one line each.- Scanner jobs decide with their own rules whether to run, are configured with variables, and upload security reports.
- They need a
teststage (DAST adaststage) unless you override their stage. - Component equivalents add inputs and version pinning. Security policies enforce scans centrally.
Practice
- (Beginner) Add SAST and Secret Detection templates to a project, run a pipeline, and list the jobs that were created and the report artifacts they uploaded.
- (Intermediate) Change the project's stages to
[build, verify, deploy]and record the error. Fix it once by overriding the stage of each scanner job and once by switching to the component equivalents with astageinput. Compare the two configurations.- Interview question: "A team says they have SAST enabled, but no SAST job ever appears in their Go service's pipeline. How would you investigate?" Hint: the analyzer jobs' own rules, the files they look for,
*_DISABLEDvariables,workflow:rules, and the Full configuration tab.
Customizing Included Templates
Why it matters. Templates are generic, and your project is not. You may need a scanner to start immediately instead of waiting for the build stage, run on a specific runner, ignore a vendored directory, or block the pipeline on findings. Copying the template to change two lines means losing every future update. GitLab's merge rules let you change exactly what you need while keeping the rest of the template current.
Techniques, from least to most invasive.
| Technique | Example | Keeps template updates |
|---|---|---|
| 1. Set documented variables | SAST_EXCLUDED_PATHS, SAST_EXCLUDED_ANALYZERS, CS_IMAGE | Yes |
| 2. Redefine the job to override some keys | semgrep-sast: {needs: [], tags: [small]} | Yes, except for the keys you override |
| 3. Disable a job | SAST_DISABLED: "true", or include the template conditionally | Yes |
4. Replace the job's rules | Your own rule list | No: the template's rules are lost |
| 5. Copy the template into your repository | ci/sast.yml | No: you own it from now on |
Overriding job keys. Redefining a job with the same name deep-merges your keys into the template's job (section 9.1, Include Merge Behavior):
include:
- template: Jobs/SAST.gitlab-ci.yml
- template: Jobs/Secret-Detection.gitlab-ci.yml
semgrep-sast:
needs: [] # start immediately instead of waiting for earlier stages
tags: [docker, small] # run on cheaper runners
variables:
SAST_EXCLUDED_PATHS: "docs, vendor" # job-level: applies to this analyzer only
artifacts:
expire_in: 2 weeks # merged into the template's artifacts: the report is kept
secret_detection:
allow_failure: false # the template allows failure. Make a failing scan block the pipeline
To confirm that the merge did what you intended, open the Full configuration tab and search for the job name. You see the template's keys and your overrides combined.
Overriding rules. rules is a list, so redefining it replaces the template's rules completely, including the checks for SAST_DISABLED and for relevant files. A job could then run in projects where the analyzer does not apply. Using !reference to the same job does not help either: after the merge, the job's rules are your new list, so the reference would point to itself. Prefer one of these approaches:
# Option A: control the whole template with include:rules, and leave job rules untouched
include:
- template: Jobs/SAST.gitlab-ci.yml
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
# Option B: keep scanning everywhere, but control the job through its documented variable
semgrep-sast:
variables:
SAST_DISABLED: $SKIP_SAST # a project or pipeline variable; "true" disables the job
If you must replace the rules, copy them from the template source for your GitLab version and add your conditions in front, then review them after every GitLab upgrade.
Extending a template's hidden jobs. Templates define hidden base jobs that their analyzer jobs extend, such as .sast-analyzer in the SAST template. You can create additional jobs from them, for example to scan a second directory with different settings. Their names are internal details and can change between GitLab versions, so check the template source before relying on them.
Upgrades. Because include:template follows the GitLab version, review your overrides when you upgrade:
- Read the release notes for changes to the templates you use.
- Test the next version with the
.latesttemplate variant on a branch before a major upgrade. - Re-check the Full configuration tab of an important project after each upgrade.
- If you need full control over timing, switch to the equivalent component and pin its version.
Key takeaways
- Customize templates with variables first, then by redefining jobs with only the keys you need to change.
- Redefined jobs are deep-merged into the template's jobs, so the rest of the template stays current.
- Replacing
rulesdiscards the template's own conditions. Control inclusion withinclude:rulesor documented*_DISABLEDvariables instead. - Copying a template is a last resort: it stops all future updates.
Practice
- (Beginner) Include the SAST template and make
semgrep-saststart immediately withneeds: []and run on a runner taggedsmall. Verify the merged job in the Full configuration tab.- (Intermediate) Make the SAST and Secret Detection templates run only in merge request and default branch pipelines, without changing any job's
rules. Then makesecret_detectionblock the pipeline when it fails, and check that the template's report artifact is still uploaded.- Interview question: "A teammate overrode
ruleson an included scanner job to add one condition, and now the scanner runs (and fails) in repositories where it does not apply. Explain why and propose a better solution." Hint: lists in a deep merge, what the template's rules were checking, and alternatives such asinclude:rulesand documented variables.
<a id="10-testing-and-code-quality"></a>