GitLab CI’s extends keyword is a powerful tool for reducing duplication in your .gitlab-ci.yml files, but its true elegance shines when you combine it with hidden jobs.
Let’s see this in action. Imagine you have a common set of jobs for building and testing your application across different languages or environments. Instead of repeating the same script, image, and artifacts definitions, you can create a base template job.
.build_template:
image: alpine:latest
script:
- echo "Building..."
- make build
artifacts:
paths:
- build/
.test_template:
image: alpine:latest
script:
- echo "Testing..."
- make test
Now, you can have your actual jobs inherit from these templates:
build_app:
extends: .build_template
test_app:
extends: .test_template
This is already useful, but what if you want to define a set of jobs that are only meant to be extended and never run directly? That’s where hidden jobs come in. Jobs starting with a dot (.) are considered hidden and won’t appear in the pipeline graph or be executed unless explicitly included by another job (via extends or include).
Consider a more complex scenario where you have multiple build environments, each with specific dependencies and build steps, but they all share a common artifact path and a final cleanup step.
.base_build_environment:
image: ubuntu:20.04
before_script:
- apt-get update && apt-get install -y build-essential
artifacts:
paths:
- dist/
.build_frontend:
extends: .base_build_environment
script:
- echo "Building frontend..."
- npm install
- npm run build
artifacts:
paths:
- dist/frontend/
.build_backend:
extends: .base_build_environment
script:
- echo "Building backend..."
- mvn clean package
artifacts:
paths:
- dist/backend.jar
.cleanup_artifacts:
script:
- echo "Cleaning up artifacts..."
- rm -rf dist/
Now, you can create your visible jobs that utilize these hidden templates:
frontend_job:
stage: build
extends:
- .build_frontend
- .cleanup_artifacts
backend_job:
stage: build
extends:
- .build_backend
- .cleanup_artifacts
Here, frontend_job will inherit the image and before_script from .base_build_environment, the script and artifacts from .build_frontend, and finally, it will also execute the script from .cleanup_artifacts. The same logic applies to backend_job. Notice how you can extend multiple hidden jobs. The order of extends matters for merging configurations, with later entries potentially overwriting earlier ones if keys conflict.
The true power here is in creating reusable, granular components of your CI/CD pipelines. You can define common base images, dependency installation steps, artifact handling, and even complex script sequences that are only ever consumed by other jobs. This dramatically improves maintainability. If you need to update the build-essential installation, you change it in one place (.base_build_environment), and it propagates everywhere it’s extended.
When GitLab parses your .gitlab-ci.yml file, it first resolves all extends directives. For a job like frontend_job, it starts with its own definition, then merges in the configurations from .build_frontend, and then merges in configurations from .cleanup_artifacts. If there are overlapping keys (like script or artifacts), the values from the later extends in the list take precedence. This merging process is recursive, meaning if a template job itself extends another job, that configuration is also brought in.
A common pitfall is forgetting that artifacts paths are additive by default when extending. If .base_build_environment defines artifacts: paths: - dist/ and .build_frontend defines artifacts: paths: - dist/frontend/, the final job will collect artifacts from both dist/ and dist/frontend/. This is usually the desired behavior for building up complex artifact sets, but it’s worth remembering if you expect overwrites.
The next logical step is to explore how include can be used in conjunction with extends and hidden jobs to manage your CI configurations across multiple files and projects.