Sachin Chaurasiya

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.

Author
Sachin Chaurasiya
Sachin Chaurasiya
Published
Reading time
14 min read
Difficulty
intermediate

Reviewed Tested with gitlab-ci-local 4.75.1, Gitea 1.24.7 (as the Git remote), node:22.23.2-alpine3.24, Trivy 0.74.0, Gitleaks 8.30.1, Docker 29.1

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.

Diagram · One template, many pipelines
One template, many pipelinesA platform team maintains a templates repository and tags releases of it. An application repository includes one template at a tagged version and passes inputs; GitLab merges the template into the pipeline, so the application pipeline runs the required jobs (build, tests, lint, secrets scan, dependency scan, package) plus any job the application adds on the template extension point. A second application includes the same template at an older tag and keeps running the older job set until it upgrades.Rendered pipelinetagv1.1.0v1.0.0pushPlatform teamci-templatesrepov1.0.0 · v1.1.01consumer-appinclude @v1.1.02other-serviceinclude @v1.0.0GitLabinclude + inputs3Required jobsbuild · test · scan4Consumer jobsextends .platform:*5

A platform team maintains a templates repository and tags releases of it. An application repository includes one template at a tagged version and passes inputs; GitLab merges the template into the pipeline, so the application pipeline runs the required jobs (build, tests, lint, secrets scan, dependency scan, package) plus any job the application adds on the template extension point. A second application includes the same template at an older tag and keeps running the older job set until it upgrades.

  1. The template repository (platform/ci-templates) holds one file per delivery path under templates/ and is tagged like any other release: v1.0.0, v1.1.0.
  2. A consumer includes one template at one tag and passes inputs. Its .gitlab-ci.yml says which path it is on and which knobs it turned, and nothing else.
  3. 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.
  4. The required jobs come from the template: build, unit tests, lint, secrets scan, dependency scan, package.
  5. 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 1 above is a suggestion.
  • CODEOWNERS on .gitlab-ci.yml routes 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 --list and 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, with needs so that lint, secrets and dependencies start immediately and only unit-tests waits for the build. A consumer adding stages between test and scan declares them in its own file; the template’s stages list 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 in spec:inputs with 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_command is 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: project at a tag is only as immutable as the tag. Protect tags in the template project so v1.0.0 cannot 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

SymptomCauseFix
Local include file cannot be foundinclude: local through a symlink, or the file is not tracked by GitVendor the file, or use include: project
input: X cannot be used because it is not in the listInput value outside optionsWorking 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 22Pass a glob: node --test "test/**/*.test.js"
A job in the template image fails with exec: "sh": not foundScanner 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 jobExpected: the tag is immutableBump ref in the consumer
--list shows a required job with allow_failure: trueThe consumer overrode itCODEOWNERS 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 --list as 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 dependencies is not on the golden path.

References

Keep reading