A GitLab CI job extends a template whose script: has three commands, but the job defines its own script: and the template's commands stop running. Why, and how do you keep both?
answer
- maps merge, arrays replace
- script is an array
- different key survives the override
- splice a fragment at a chosen position
- the custom YAML tag GitLab adds
basics
~20 sGitLab's extends merges hash maps but replaces arrays, and script: is an array, so the job's own list overwrites the template's entirely. Splice the template's commands back in with the !reference tag, or move the shared commands into before_script.
solid answer
~40 s`extends:` does a recursive merge on **maps** but a straight replacement on **arrays**. `script:` is an array, so defining it in the job discards the template's version — no warning, the commands simply are not in the merged job. Two fixes. If the shared commands are setup, move them to `before_script:` in the template; that is a different key, so it survives alongside the job's `script:`. If you need them interleaved with your own commands, use GitLab's `!reference` tag: `- !reference [.template, script]` inside your `script:` list splices that job's array in at that position. `!reference` can point at any nested key of any job, hidden or not, including one that arrived through an `include:`, and referenced arrays are flattened into the surrounding list.
go deeper
Know that defining script: in a job replaces whatever script: the template had, and that the shared commands are gone rather than merged. Recall that GitLab has a way to splice one job's list into another.
State the rule precisely — maps merge, arrays replace — name the array keys it applies to, and show both fixes: moving setup into before_script, or using !reference to splice the template's array at a chosen position.
Diagnose this from symptoms: the job fails on missing credentials, and you go to the merged-configuration view rather than the runner logs. Be ready to say why array replacement is a deliberate design choice, not a defect.
Set the convention for a shared template library: which keys templates are allowed to own, whether teams compose fragments or override wholesale, and how you stop rules: overrides from quietly widening where a governed job runs.
## The failure ```yaml .deploy-base: script: - ./scripts/auth.sh - ./scripts/fetch-config.sh - ./scripts/deploy.sh deploy-staging: extends: .deploy-base script: - ./scripts/deploy.sh --env staging ``` The job runs exactly one command. Nothing errors, nothing warns; the two setup commands were never in the merged job at all. The first symptom is usually a runtime failure deep in the deploy — missing credentials, missing config — that looks like an application problem rather than a pipeline-merge problem. ## Why: maps merge, arrays replace GitLab's `extends` implementation performs a recursive merge over hash maps. `variables:`, `cache:`, `artifacts:` and `retry:` are maps, so template values and job values coexist. But `script:`, `before_script:`, `after_script:`, `tags:`, `rules:`, `needs:` and `dependencies:` are **arrays**, and arrays are not merged — the extending job's array replaces the template's outright. This is a deliberate design decision, not a bug: silently concatenating command lists would make the execution order of a shared template unpredictable, and a job could never *remove* an inherited command. The cost is that the most obvious thing a candidate wants to do — "add one command to the template's script" — is the thing `extends` will not do. Confirm it rather than guess: the pipeline editor's merged-configuration view shows the fully resolved job, and the missing commands are visible immediately. ## Fix 1: use a different key If the shared commands are *setup*, they belong in `before_script:`: ```yaml .deploy-base: before_script: - ./scripts/auth.sh - ./scripts/fetch-config.sh deploy-staging: extends: .deploy-base script: - ./scripts/deploy.sh --env staging ``` Now the job overrides `script:` and never touches `before_script:`, so both survive the merge. This is the cleanest answer when the split is genuinely setup-then-work, and it needs no GitLab-specific syntax at all. It stops working the moment you need the shared commands *after* or *between* your own. ## Fix 2: `!reference` `!reference` is a custom YAML tag GitLab adds. It takes a path — a job name followed by the keys to walk — and inserts the value found there: ```yaml .setup: script: - ./scripts/auth.sh - ./scripts/fetch-config.sh deploy-staging: script: - !reference [.setup, script] - ./scripts/deploy.sh --env staging ``` The merged job runs three commands in that order. Points worth knowing: - **It works across includes.** Unlike a YAML anchor, `!reference` is resolved by GitLab after every included file has been merged, so it can point at a hidden job that came from a shared template project. That makes it the array-level counterpart to `extends`. - **It can address nested keys**, not just `script` — `!reference [.setup, before_script]` or a key inside `variables:` are both valid targets. - **Referenced arrays are flattened** into the surrounding list, so you get one flat command list rather than a nested one. - **You control position.** Putting the reference last runs the shared commands after yours — something `before_script` cannot express. - **It is not recursive without limit.** References may nest, but GitLab caps the nesting depth, and a self-referential chain is a configuration error caught at pipeline creation. - **It works alongside `extends`.** A job can extend a template for its map keys and reference another job's array in its script. ## Choosing between them Use `extends` for the shape of a job — image, tags, rules, variables, artifacts. Use `before_script`/`after_script` when the shared work is genuinely a prologue or epilogue. Use `!reference` when you need a named, reusable *fragment* of commands composed into a specific position. A template repository usually ends up with all three: hidden jobs holding the job shape, and small hidden jobs holding command fragments that `!reference` splices in. The anti-pattern is discovering array replacement and reacting by copying the template's three commands into every job. That reintroduces exactly the duplication the template existed to remove, and the next change to `auth.sh` invocation has to be made in fifty places. ## The diagnostic habit Any time a job "is not doing what the template says", the first move is the merged view, and the first hypothesis is an array key that got replaced. It is by far the most common way a GitLab template hierarchy silently misbehaves.
- Which other job keys are silently replaced the same way script: is?All the array-valued ones: `before_script`, `after_script`, `tags`, `rules`, `needs` and `dependencies`. `rules:` is the dangerous one — a job that redefines `rules:` loses the template's conditions entirely, so it can start running on branches the platform team never intended. Map-valued keys such as `variables:`, `cache:` and `artifacts:` are merged instead.
- Can !reference point at a job in a file pulled in by include:?Yes. `!reference` is resolved by GitLab after all included files have been merged into one configuration, so any job name in the merged config — hidden or real, local or included — is a valid target. That is precisely what a YAML anchor cannot do, since anchors are scoped to the single document the parser read.
- When would you prefer before_script to !reference for shared setup?When the shared work is genuinely a prologue that every job in the family needs and no job needs to interleave. `before_script:` is plain GitLab configuration that any reader understands, and it survives a job overriding `script:` for free. Reach for `!reference` only when position matters or when several unrelated fragments must be composed.
saying these in an interview costs you the question
- Expecting extends to append to the template's script
- Copying the template's commands into every job
- Thinking the missing commands are a runner bug
- Assuming rules: from a template always survives an override
- Using a YAML anchor to reach a job in an included file