CI/CD Part 3 of 5 · Platform Engineering
Reusable GitLab CI Templates and Components: A Golden Delivery Path
One platform-owned GitLab CI template instead of a pipeline per repository: spec:inputs with validation, a consumer that includes it by project and tag, extension points, versioning, and what a consumer can override.
On this page
Overview
Every repository that writes its own .gitlab-ci.yml makes the same twelve decisions: which image, how to
cache, which scanner, which severity, what to do with the artifact. Most of them get most decisions right, and
the one that pins node:latest or sets allow_failure: true on the dependency scan is the one that matters.
The fix is not a review checklist. It is one pipeline definition, owned by the people who make those decisions
for a living, that a repository can adopt in five lines and extend without editing.
This article builds that definition for a Node.js service, on the pipeline shape from the CI/CD Engineering path and the security jobs from the DevSecOps Pipeline path, and then looks at the part templates get wrong: what a consumer can override, and what stops them. Every pipeline below was run with gitlab-ci-local 4.75.1 against a local Gitea acting as the GitLab remote. Two features could not be exercised locally and are marked as such: publishing a component to the CI/CD Catalog, and compliance pipelines.
- The template repository (
platform/ci-templates) holds one file per delivery path undertemplates/and is tagged like any other release:v1.0.0,v1.1.0. - A consumer includes one template at one tag and passes inputs. Its
.gitlab-ci.ymlsays which path it is on and which knobs it turned, and nothing else. - GitLab merges the included file and the consumer’s own jobs into one pipeline at pipeline creation. Inputs are substituted before the merge; an input outside its allowed values fails the pipeline before any job runs.
- The required jobs come from the template: build, unit tests, lint, secrets scan, dependency scan, package.
- Consumer jobs attach to the template’s hidden extension jobs (
.platform:test) and run beside the required ones. A second consumer on an older tag keeps the older job set until it chooses to move.
The template
spec:
inputs:
node_image:
description: 'Node.js image for every job.'
default: 'node:22.23.2-alpine3.24'
test_command:
description: 'Command that runs the unit tests and writes junit.xml.'
default: 'npm test'
lint_command:
default: 'npm run lint'
scan_severity:
description: 'Severities that fail the dependency scan. Fixable findings only.'
default: 'HIGH,CRITICAL'
options: ['CRITICAL', 'HIGH,CRITICAL', 'MEDIUM,HIGH,CRITICAL']
artifact_expiry:
default: '1 week'
---
stages: [build, test, scan, package]
default:
image: $[[ inputs.node_image ]]
interruptible: true
variables:
npm_config_cache: .npm
cache:
key:
files: [package-lock.json]
paths: [.npm/]
policy: pull
# --- Extension points ---------------------------------------------------------
# Consumers extend these hidden jobs (extends: .platform:test) to add steps
# without redefining the required jobs below.
.platform:node-install:
before_script:
- npm ci --prefer-offline --no-audit --no-fund
.platform:test:
extends: .platform:node-install
stage: test
needs: [build]
# --- Required path --------------------------------------------------------------
build:
extends: .platform:node-install
stage: build
cache:
key:
files: [package-lock.json]
paths: [.npm/]
policy: pull-push
script:
- npm run build
artifacts:
paths: [dist/]
expire_in: $[[ inputs.artifact_expiry ]]
unit-tests:
extends: .platform:test
script:
- $[[ inputs.test_command ]]
artifacts:
when: always
reports:
junit: junit.xml
lint:
extends: .platform:node-install
stage: test
needs: []
script:
- $[[ inputs.lint_command ]]
secrets:
stage: scan
needs: []
image:
name: zricethezav/gitleaks:v8.30.1
entrypoint: ['']
script:
- gitleaks dir . --no-banner --redact --exit-code 1
dependencies:
stage: scan
needs: []
image:
name: aquasec/trivy:0.74.0
entrypoint: ['']
script:
- trivy fs --scanners vuln --severity $[[ inputs.scan_severity ]] --ignore-unfixed --exit-code 1 --no-progress .
package:
stage: package
needs: [build, unit-tests, lint, secrets, dependencies]
script:
- tar -czf "app-${CI_COMMIT_SHORT_SHA}.tar.gz" dist/
- sha256sum "app-${CI_COMMIT_SHORT_SHA}.tar.gz" > "app-${CI_COMMIT_SHORT_SHA}.sha256"
- cat "app-${CI_COMMIT_SHORT_SHA}.sha256"
artifacts:
paths: ['app-*.tar.gz', 'app-*.sha256']
expire_in: $[[ inputs.artifact_expiry ]]
The file has two halves separated by ---. The spec is the contract: five inputs, each with a default, one
with an allowed list. The body is the pipeline, with $[[ inputs.x ]] where a consumer is allowed a say.
Everything that is not an input is not negotiable through this file: the scanner, --ignore-unfixed,
--exit-code 1, the artifact checksum, the job graph. That asymmetry is the design. Inputs are the surface the
platform team chose to expose; the rest is the golden path.
The .platform:* hidden jobs are the extension points. unit-tests itself is built on .platform:test, so a
consumer’s extra test job gets the same image, cache, install step and needs: [build] by extending it, and the
platform team can change what “a test job” means in one place.
The consumer
include:
- project: platform/ci-templates
ref: v1.0.0
file: templates/node-service.yml
inputs:
scan_severity: 'MEDIUM,HIGH,CRITICAL'
Five lines, and the pipeline they produce:
npx gitlab-ci-local --list
downloaded platform/ci-templates v1.0.0 templates/node-service.yml in 414 ms
name stage when allow_failure needs
build build on_success false
unit-tests test on_success false [build]
lint test on_success false []
secrets scan on_success false []
dependencies scan on_success false []
package package on_success false [build,unit-tests,lint,secrets,dependencies]
gitlab-ci-local resolved project: platform/ci-templates against the consumer’s Git remote (the local Gitea),
fetched the file at tag v1.0.0, and substituted the input. --preview shows the merged result, with
--severity MEDIUM,HIGH,CRITICAL in the dependencies script and every other value at its default. The full
run:
npx gitlab-ci-local
build starting node:22.23.2-alpine3.24 (build)
secrets starting zricethezav/gitleaks:v8.30.1 (scan)
lint starting node:22.23.2-alpine3.24 (test)
dependencies starting aquasec/trivy:0.74.0 (scan)
secrets > 5:53AM INF scanned ~5531 bytes (5.53 KB) in 28.3ms
secrets > 5:53AM INF no leaks found
dependencies > │ package-lock.json │ npm │ 0 │
unit-tests starting node:22.23.2-alpine3.24 (test)
package > 0651bbcd421b9a189659f9dbdbaa27652c9dff15e85bcd1131affebc335ed9af app-79658127.tar.gz
PASS build
PASS unit-tests
PASS lint
PASS secrets
PASS dependencies
PASS package
pipeline finished in 3.75 min
Six jobs, two scanners, a checksummed artifact, and the consumer wrote none of it. Of the 3.75 minutes, most is
the first pull of the scanner images; a second run of the same commit took 2.5 minutes, and the largest remaining
item is Trivy downloading its vulnerability database on every run. Caching it (TRIVY_CACHE_DIR under cache:)
is the kind of change a template exists for: one merge request, every consumer faster.
An input outside its options
inputs:
scan_severity: 'LOW'
This GitLab CI configuration is invalid: `scan_severity` input: `LOW` cannot be used because it is not in the
list of allowed options.
The pipeline does not exist. This is the difference between an input and a variable: a variable is a string the
job interprets at run time, an input is validated when the pipeline is created, against the list the template
author wrote. options is the cheapest guard rail in the file.
Extending without editing
include:
- project: platform/ci-templates
ref: v1.1.0
file: templates/node-service.yml
inputs:
scan_severity: 'MEDIUM,HIGH,CRITICAL'
contract-tests:
extends: .platform:test
script:
- node --test "test/**/*.test.js"
npx gitlab-ci-local contract-tests --needs
build finished in 3.57 s
contract-tests $ npm ci --prefer-offline --no-audit --no-fund
contract-tests $ node --test "test/**/*.test.js"
contract-tests > # tests 2
contract-tests > # pass 2
PASS build
PASS contract-tests
The new job inherited the image, the cache, the install step and needs: [build] from the extension point,
and the consumer wrote three lines. It runs in the test stage beside unit-tests; package still needs
only the required jobs, so a consumer’s extra test cannot be forgotten by package but also cannot block it
unless the consumer adds it to a needs list of their own.
Versioning the template
v1.1.0 above is not decoration. Between the two tags the template gained an SBOM job in the scan stage:
sbom:
stage: scan
needs: [build]
image:
name: aquasec/trivy:0.74.0
entrypoint: ['']
script:
- trivy fs --format cyclonedx --output sbom.cdx.json --quiet .
artifacts:
paths: [sbom.cdx.json]
expire_in: $[[ inputs.artifact_expiry ]]
package:
needs: [build, unit-tests, lint, secrets, dependencies, sbom]
The same consumer file, listed at each tag:
v1.0.0: build unit-tests lint contract-tests secrets dependencies package
v1.1.0: build unit-tests lint contract-tests secrets dependencies sbom package
A consumer pinned to v1.0.0 did not get the new job, which is the property that makes a template safe to
change: the platform team ships v1.1.0, announces it, and each repository moves when it is ready. ref: main
would give every repository every change at the moment it merges, including the broken ones. Tag the template,
pin the consumer, and treat a tag bump like a dependency update: a merge request in the consumer, with the
pipeline as its own test.
Semantic versioning maps cleanly. Adding an input with a default or adding a job is a minor release. Removing an
input, renaming an extension point or changing what scan_severity means is a major one. The
dependency-pinning argument from the CI/CD
Engineering path applies to the pipeline definition exactly as it applies to package-lock.json.
Components and the catalog
GitLab’s CI/CD components are this same file with a delivery mechanism around it. A component is a templates/
file with a spec:inputs header in a project that has been released to the CI/CD Catalog; the consumer includes
it by address instead of by project and file:
include:
- component: gitlab.example.com/platform/ci-templates/node-service@1.1.0
inputs:
scan_severity: 'MEDIUM,HIGH,CRITICAL'
The repository above already has the component layout, and gitlab-ci-local resolved the component: form
against the local Gitea by mapping the address to templates/node-service.yml at the tag: the listing was
identical to the project: include. What was not executed here is the catalog itself: marking the project as a
catalog resource and creating a release so the version appears in GitLab’s component browser. That needs a
GitLab instance and is configuration only in this article. The pipeline semantics (inputs, validation, merge
order) are the same for both include forms; the catalog adds discoverability and a version list, not behaviour.
What a consumer can override, and what stops them
This is the section most template documentation skips. GitLab merges an included file and the consumer’s file into one configuration, and the consumer’s keys win. A consumer can therefore write this:
include:
- project: platform/ci-templates
ref: v1.0.0
file: templates/node-service.yml
dependencies:
allow_failure: true
name stage when allow_failure needs
dependencies scan on_success true []
It worked. The dependency scan now runs and is ignored, and nothing in the template could have prevented it: an
include is composition, not enforcement. The same is true of rules: [{ when: never }] on secrets, of a
script: that replaces the scanner with echo, and of not including the template at all. A template is a
golden path in the sense that it is the easy path; it is not a fence.
What is a fence, in order of strength:
- Protected branches with “pipeline must succeed” make a red job block a merge; without it, every
exit-code 1above is a suggestion. CODEOWNERSon.gitlab-ci.ymlroutes any change to the consumer’s pipeline file, including the override above, through the platform team’s approval. This is the control the IaC gate article relies on, and it is cheap.- Compliance pipelines (a GitLab Ultimate feature: a pipeline configuration attached to a compliance
framework that runs before and regardless of the project’s own
.gitlab-ci.yml) are enforcement in the proper sense: the required jobs come from a project the consumer cannot edit and cannot skip. Not exercised here; it needs a GitLab instance on that tier. - Reviewing the rendered pipeline, not the source.
gitlab-ci-local --listand GitLab’s own “view merged YAML” show what will actually run after includes and overrides; a review of the consumer’s five lines does not.
The honest summary for a platform team: the template makes the right pipeline the default, the input options
make the wrong values impossible, CODEOWNERS makes the overrides visible, and only the compliance tier makes
them impossible. Say which of those you have.
Conventions the template carries
A golden path is also the place where conventions live so that nobody has to remember them:
- Artifacts: one tarball named by commit SHA with a checksum beside it, kept for
artifact_expiry. A deploy job downstream verifies the checksum before it does anything, as in Rollback and Recovery. - Stages:
build → test → scan → package, withneedsso thatlint,secretsanddependenciesstart immediately and onlyunit-testswaits for the build. A consumer adding stages betweentestandscandeclares them in its own file; the template’sstageslist is merged, not replaced. - Images: pinned tags everywhere, including the scanners. The template is where a base-image bump happens once for every consumer.
- Environments: this template stops at the package. Deployment is deliberately a separate template, because the identity that deploys (a protected-branch job with a short-lived credential, Cloud Security Foundations, part 1) has different rules from the identity that builds.
- No hidden behaviour: every job in the rendered pipeline is visible in
--list, every value a consumer can change is inspec:inputswith a description, and the template’s own repository has a changelog per tag. A consumer who cannot predict what their pipeline will do from their five lines and the template’s README is a consumer who will fork the template.
Security implications
- The template repository is now the most privileged CI configuration in the organisation: a change to it
changes every consumer’s next pipeline. Protect it like production code: protected
main, tagged releases, two reviewers, its own pipeline that runs the template against a sample consumer. - Inputs are substituted as text.
test_commandis executed by a shell; a consumer can put anything in it, and that is intended, but a template must never pass an input into a job that runs with a credential the consumer should not have. include: projectat a tag is only as immutable as the tag. Protect tags in the template project sov1.0.0cannot be moved after consumers pin it.- Scanner images are pinned to versions in the template; the vulnerability database they download is not. Two runs on the same commit can differ, which is why the scan job is a gate on the merge request and not a reproducible artifact.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
Local include file cannot be found | include: local through a symlink, or the file is not tracked by Git | Vendor the file, or use include: project |
input: X cannot be used because it is not in the list | Input value outside options | Working as intended; pick an allowed value or change the template |
| A consumer job “Cannot find module …/test” | node --test <dir> does not walk directories in Node 22 | Pass a glob: node --test "test/**/*.test.js" |
A job in the template image fails with exec: "sh": not found | Scanner image without a shell (some distroless builds) | Use an image variant with a shell, or a different tool image for that job |
| Consumer pinned to a tag does not get a new job | Expected: the tag is immutable | Bump ref in the consumer |
--list shows a required job with allow_failure: true | The consumer overrode it | CODEOWNERS on .gitlab-ci.yml; compliance pipeline if enforcement is needed |
Running this in production
- One template per delivery shape (Node service, Go service, Terraform module, static site), each small enough to read in one sitting. A template that needs a flowchart is three templates.
- Version with tags, publish a changelog, and keep an “upgrade” section per release that says what a consumer has to change.
- Run the template’s own pipeline on every change: a sample consumer per template,
gitlab-ci-local --listas a unit test of the job graph, and a full run before tagging. - Measure adoption by the rendered pipeline, not by the include line: a repository that includes the template
and overrides
dependenciesis not on the golden path.
References
Keep reading